[Security Solution] [Cases] Swimlane Connector for Cases (#100086) (#103165)

Co-authored-by: Josh <josh.rickard@swimlane.com>
Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>
Co-authored-by: Josh <josh.rickard@swimlane.com>
Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2021-06-24 00:25:31 +03:00 committed by GitHub
parent dbfb32bbfd
commit c6e6462e2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
110 changed files with 5531 additions and 233 deletions

View file

@ -43,6 +43,10 @@ a| <<slack-action-type, Slack>>
| Send a message to a Slack channel or user.
a| <<swimlane-action-type, Swimlane>>
| Create an incident in Swimlane.
a| <<webhook-action-type, Webhook>>
| Send a request to a web service.

View file

@ -0,0 +1,105 @@
[role="xpack"]
[[swimlane-action-type]]
=== Swimlane connector and action
++++
<titleabbrev>Swimlane</titleabbrev>
++++
The Swimlane connector uses the https://swimlane.com/knowledge-center/docs/developer-guide/rest-api/[Swimlane REST API] to create Swimlane records.
[float]
[[swimlane-connector-configuration]]
==== Connector configuration
Swimlane connectors have the following configuration properties.
Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action.
URL:: Swimlane instance URL.
Application ID:: Swimlane application ID.
API token:: Swimlane API authentication token for HTTP Basic authentication.
[float]
[[Preconfigured-swimlane-configuration]]
==== Preconfigured connector type
[source,text]
--
my-swimlane:
name: preconfigured-swimlane-connector-type
actionTypeId: .swimlane
config:
apiUrl: https://elastic.swimlaneurl.us
appId: app-id
mappings:
alertIdConfig:
fieldType: text
id: agp4s
key: alert-id
name: Alert ID
caseIdConfig:
fieldType: text
id: ae1mi
key: case-id
name: Case ID
caseNameConfig:
fieldType: text
id: anxnr
key: case-name
name: Case Name
commentsConfig:
fieldType: comments
id: au18d
key: comments
name: Comments
descriptionConfig:
fieldType: text
id: ae1gd
key: description
name: Description
ruleNameConfig:
fieldType: text
id: avfsl
key: rule-name
name: Rule Name
severityConfig:
fieldType: text
id: a71ik
key: severity
name: severity
secrets:
apiToken: tokenkeystorevalue
--
Config defines information for the connector type.
`apiUrl`:: An address that corresponds to *URL*.
`appId`:: A key that corresponds to *Application ID*.
Secrets defines sensitive information for the connector type.
`apiToken`:: A string that corresponds to *API Token*. Should be stored in the <<creating-keystore, {kib} keystore>>.
[float]
[[define-swimlane-ui]]
==== Define connector in Stack Management
Define Swimlane connector properties.
[role="screenshot"]
image::management/connectors/images/swimlane-connector.png[Swimlane connector]
Test Swimlane action parameters.
[role="screenshot"]
image::management/connectors/images/swimlane-params-test.png[Swimlane params test]
[float]
[[swimlane-action-configuration]]
==== Action configuration
Swimlane actions have the following configuration properties.
Comments:: Additional information for the client, such as how to troubleshoot the issue.
Severity:: The severity of the incident.
NOTE: Alert ID and Rule Name are filled automatically. Specifically, Alert ID is set to `{{alert.id}}` and Rule Name to `{{rule.name}}`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View file

@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[]
include::action-types/pagerduty.asciidoc[]
include::action-types/server-log.asciidoc[]
include::action-types/servicenow.asciidoc[]
include::action-types/swimlane.asciidoc[]
include::action-types/slack.asciidoc[]
include::action-types/webhook.asciidoc[]
include::pre-configured-connectors.asciidoc[]

View file

@ -19,7 +19,7 @@ Table of Contents
- [Usage](#usage)
- [Kibana Actions Configuration](#kibana-actions-configuration)
- [Configuration Options](#configuration-options)
- [Adding Built-in Action Types to allowedHosts](#adding-built-in-action-types-to-allowedhosts)
- [**allowedHosts** configuration](#allowedhosts-configuration)
- [Configuration Utilities](#configuration-utilities)
- [Action types](#action-types)
- [Methods](#methods)
@ -54,6 +54,9 @@ Table of Contents
- [`subActionParams (getFields)`](#subactionparams-getfields-2)
- [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes)
- [`subActionParams (severity)`](#subactionparams-severity)
- [Swimlane](#swimlane)
- [`params`](#params-3)
- [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-)
- [Command Line Utility](#command-line-utility)
- [Developing New Action Types](#developing-new-action-types)
- [licensing](#licensing)
@ -102,8 +105,8 @@ This module provides utilities for interacting with the configuration.
| ensureUriAllowed | _uri_: The URI you wish to validate is allowed | Validates whether the URI is allowed. This checks the configuration and validates that the hostname of the URI is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all URI's are allowed (using an "\*") then it will never throw. | No return value, throws if URI isn't allowed |
| ensureHostnameAllowed | _hostname_: The Hostname you wish to validate is allowed | Validates whether the Hostname is allowed. This checks the configuration and validates that the hostname is in the list of allowed Hosts and throws an error if it is not allowed. If the configuration says that all Hostnames are allowed (using an "\*") then it will never throw | No return value, throws if Hostname isn't allowed . |
| ensureActionTypeEnabled | _actionType_: The actionType to check to see if it's enabled | Throws an error if the actionType is not enabled | No return value, throws if actionType isn't enabled |
| isRejectUnauthorizedCertificatesEnabled | _none_ | Returns value of `rejectUnauthorized` from configuration. | Boolean |
| getProxySettings | _none_ | If `proxyUrl` is set in the configuration, returns the proxy settings `proxyUrl`, `proxyHeaders` and `proxyRejectUnauthorizedCertificates`. Otherwise returns _undefined_. | Undefined or ProxySettings |
| isRejectUnauthorizedCertificatesEnabled | _none_ | Returns value of `rejectUnauthorized` from configuration. | Boolean |
| getProxySettings | _none_ | If `proxyUrl` is set in the configuration, returns the proxy settings `proxyUrl`, `proxyHeaders` and `proxyRejectUnauthorizedCertificates`. Otherwise returns _undefined_. | Undefined or ProxySettings |
## Action types
@ -113,17 +116,17 @@ This module provides utilities for interacting with the configuration.
The following table describes the properties of the `options` object.
| Property | Description | Type |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `<plugin_id>.mySpecialAction` for your action types. | string |
| name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string |
| maxAttempts | The maximum number of times this action will attempt to execute when scheduled. | number |
| minimumLicenseRequired | The license required to use the action type. | string |
| Property | Description | Type |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| id | Unique identifier for the action type. For convention, ids starting with `.` are reserved for built in action types. We recommend using a convention like `<plugin_id>.mySpecialAction` for your action types. | string |
| name | A user-friendly name for the action type. These will be displayed in dropdowns when chosing action types. | string |
| maxAttempts | The maximum number of times this action will attempt to execute when scheduled. | number |
| minimumLicenseRequired | The license required to use the action type. | string |
| validate.params | When developing an action type, it needs to accept parameters to know what to do with the action. (Example `to`, `from`, `subject`, `body` of an email). See the current built-in email action type for an example of the state-of-the-art validation. <p>Technically, the value of this property should have a property named `validate()` which is a function that takes a params object to validate and returns a sanitized version of that object to pass to the execution function. Validation errors should be thrown from the `validate()` function and will be available as an error message | schema / validation function |
| validate.config | Similar to params, a config may be required when creating an action (for example `host` and `port` for an email server). | schema / validation function |
| validate.secrets | Similar to params, a secrets object may be required when creating an action (for example `user` and `password` for an email server). | schema / validation function |
| executor | This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function |
| renderParameterTemplates | Optionally define a function to provide custom rendering for this action type. | Function |
| validate.config | Similar to params, a config may be required when creating an action (for example `host` and `port` for an email server). | schema / validation function |
| validate.secrets | Similar to params, a secrets object may be required when creating an action (for example `user` and `password` for an email server). | schema / validation function |
| executor | This is where the code of an action type lives. This is a function gets called for executing an action from either alerting or manually by using the exposed function (see firing actions). For full details, see executor section below. | Function |
| renderParameterTemplates | Optionally define a function to provide custom rendering for this action type. | Function |
**Important** - The config object is persisted in ElasticSearch and updated via the ElasticSearch update document API. This API allows "partial updates" - and this can cause issues with the encryption used on specified properties. So, a `validate()` function should return values for all configuration properties, so that partial updates do not occur. Setting property values to `null` rather than `undefined`, or not including a property in the config object, is all you need to do to ensure partial updates won't occur.
@ -133,15 +136,15 @@ This is the primary function for an action type. Whenever the action needs to ex
**executor(options)**
| Property | Description |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| actionId | The action saved object id that the action type is executing for. |
| config | The action configuration. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. |
| secrets | The decrypted secrets object given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the secrets object before being passed to the executor, define `validate.secrets` within the action type. |
| params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. |
| services.scopedClusterClient | Use this to do Elasticsearch queries on the cluster Kibana connects to. Serves the same purpose as the normal IClusterClient, but exposes an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead.|
| services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.<br><br>The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). |
| services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log)
| Property | Description |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| actionId | The action saved object id that the action type is executing for. |
| config | The action configuration. If you would like to validate the config before being passed to the executor, define `validate.config` within the action type. |
| secrets | The decrypted secrets object given to an action. This comes from the action saved object that is partially or fully encrypted within the data store. If you would like to validate the secrets object before being passed to the executor, define `validate.secrets` within the action type. |
| params | Parameters for the execution. These will be given at execution time by either an alert or manually provided when calling the plugin provided execute function. |
| services.scopedClusterClient | Use this to do Elasticsearch queries on the cluster Kibana connects to. Serves the same purpose as the normal IClusterClient, but exposes an additional `asCurrentUser` method that doesn't use credentials of the Kibana internal user (as `asInternalUser` does) to request Elasticsearch API, but rather passes HTTP headers extracted from the current user request to the API instead. |
| services.savedObjectsClient | This is an instance of the saved objects client. This provides the ability to do CRUD on any saved objects within the same space the alert lives in.<br><br>The scope of the saved objects client is tied to the user in context calling the execute API or the API key provided to the execute plugin function (only when security isenabled). |
| services.log(tags, [data], [timestamp]) | Use this to create server logs. (This is the same function as server.log) |
### Example
@ -262,16 +265,16 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib
The following table describes the properties of the `incident` object.
| Property | Description | Type |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| short_description | The title of the incident. | string |
| description | The description of the incident. | string _(optional)_ |
| Property | Description | Type |
| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- |
| short_description | The title of the incident. | string |
| description | The description of the incident. | string _(optional)_ |
| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
| severity | The severity in ServiceNow. | string _(optional)_ |
| urgency | The urgency in ServiceNow. | string _(optional)_ |
| impact | The impact in ServiceNow. | string _(optional)_ |
| category | The category in ServiceNow. | string _(optional)_ |
| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
| severity | The severity in ServiceNow. | string _(optional)_ |
| urgency | The urgency in ServiceNow. | string _(optional)_ |
| impact | The impact in ServiceNow. | string _(optional)_ |
| category | The category in ServiceNow. | string _(optional)_ |
| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
#### `subActionParams (getFields)`
@ -311,20 +314,20 @@ The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/ma
The following table describes the properties of the `incident` object.
| Property | Description | Type |
| ----------- | ---------------------------------------------------------------------------------------------------------------- | --------------------- |
| summary | The title of the issue. | string |
| description | The description of the issue. | string _(optional)_ |
| Property | Description | Type |
| ----------- | ------------------------------------------------------------------------------------------------------- | --------------------- |
| summary | The title of the issue. | string |
| description | The description of the issue. | string _(optional)_ |
| externalId | The ID of the issue in Jira. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
| issueType | The ID of the issue type in Jira. | string _(optional)_ |
| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ |
| labels | An array of labels. Labels cannot contain spaces. | string[] _(optional)_ |
| parent | The ID or key of the parent issue. Only for `Sub-task` issue types. | string _(optional)_ |
| issueType | The ID of the issue type in Jira. | string _(optional)_ |
| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ |
| labels | An array of labels. Labels cannot contain spaces. | string[] _(optional)_ |
| parent | The ID or key of the parent issue. Only for `Sub-task` issue types. | string _(optional)_ |
#### `subActionParams (getIncident)`
| Property | Description | Type |
| ---------- | --------------------------- | ------ |
| Property | Description | Type |
| ---------- | ---------------------------- | ------ |
| externalId | The ID of the issue in Jira. | string |
#### `subActionParams (issueTypes)`
@ -333,20 +336,20 @@ No parameters for the `issueTypes` subaction. Provide an empty object `{}`.
#### `subActionParams (fieldsByIssueType)`
| Property | Description | Type |
| -------- | -------------------------------- | ------ |
| Property | Description | Type |
| -------- | --------------------------------- | ------ |
| id | The ID of the issue type in Jira. | string |
#### `subActionParams (issues)`
| Property | Description | Type |
| -------- | ----------------------- | ------ |
| Property | Description | Type |
| -------- | ------------------------ | ------ |
| title | The title to search for. | string |
#### `subActionParams (issue)`
| Property | Description | Type |
| -------- | --------------------------- | ------ |
| Property | Description | Type |
| -------- | ---------------------------- | ------ |
| id | The ID of the issue in Jira. | string |
#### `subActionParams (getFields)`
@ -360,10 +363,10 @@ The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/
### `params`
| Property | Description | Type |
| --------------- | -------------------------------------------------------------------------------------------------- | ------ |
| Property | Description | Type |
| --------------- | ------------------------------------------------------------------------------------------------- | ------ |
| subAction | The subaction to perform. It can be `pushToService`, `getFields`, `incidentTypes`, and `severity. | string |
| subActionParams | The parameters of the subaction. | object |
| subActionParams | The parameters of the subaction. | object |
#### `subActionParams (pushToService)`
@ -374,13 +377,13 @@ The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/
The following table describes the properties of the `incident` object.
| Property | Description | Type |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| name | The title of the incident. | string _(optional)_ |
| description | The description of the incident. | string _(optional)_ |
| Property | Description | Type |
| ------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------- |
| name | The title of the incident. | string _(optional)_ |
| description | The description of the incident. | string _(optional)_ |
| externalId | The ID of the incident in IBM Resilient. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
| incidentTypes | An array with the IDs of IBM Resilient incident types. | number[] _(optional)_ |
| severityCode | IBM Resilient ID of the severity code. | number _(optional)_ |
| incidentTypes | An array with the IDs of IBM Resilient incident types. | number[] _(optional)_ |
| severityCode | IBM Resilient ID of the severity code. | number _(optional)_ |
#### `subActionParams (getFields)`
@ -394,6 +397,36 @@ No parameters for the `incidentTypes` subaction. Provide an empty object `{}`.
No parameters for the `severity` subaction. Provide an empty object `{}`.
---
## Swimlane
### `params`
| Property | Description | Type |
| --------------- | ---------------------------------------------------- | ------ |
| subAction | The subaction to perform. It can be `pushToService`. | string |
| subActionParams | The parameters of the subaction. | object |
`subActionParams (pushToService)`
| Property | Description | Type |
| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- |
| incident | The Swimlane incident. | object |
| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ |
The following table describes the properties of the `incident` object.
| Property | Description | Type |
| ----------- | -------------------------------- | ------------------- |
| alertId | The alert id. | string _(optional)_ |
| caseId | The case id of the incident. | string _(optional)_ |
| caseName | The case name of the incident. | string _(optional)_ |
| description | The description of the incident. | string _(optional)_ |
| ruleName | The rule name. | string _(optional)_ |
| severity | The severity of the incident. | string _(optional)_ |
---
# Command Line Utility

View file

@ -21,6 +21,7 @@ const ACTION_TYPE_IDS = [
'.pagerduty',
'.server-log',
'.slack',
'.swimlane',
'.teams',
'.webhook',
];

View file

@ -12,6 +12,7 @@ import { Logger } from '../../../../../src/core/server';
import { getActionType as getEmailActionType } from './email';
import { getActionType as getIndexActionType } from './es_index';
import { getActionType as getPagerDutyActionType } from './pagerduty';
import { getActionType as getSwimlaneActionType } from './swimlane';
import { getActionType as getServerLogActionType } from './server_log';
import { getActionType as getSlackActionType } from './slack';
import { getActionType as getWebhookActionType } from './webhook';
@ -65,6 +66,7 @@ export function registerBuiltInActionTypes({
);
actionTypeRegistry.register(getIndexActionType({ logger }));
actionTypeRegistry.register(getPagerDutyActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getSwimlaneActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getServerLogActionType({ logger }));
actionTypeRegistry.register(getSlackActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities }));

View file

@ -25,7 +25,7 @@ import {
JiraSecretConfigurationType,
JiraExecutorResultData,
ExecutorSubActionGetFieldsByIssueTypeParams,
ExecutorSubActionGetIssueTypesParams,
ExecutorSubActionCommonFieldsParams,
ExecutorSubActionGetIssuesParams,
ExecutorSubActionGetIssueParams,
ExecutorSubActionGetIncidentParams,
@ -137,7 +137,7 @@ async function executor(
}
if (subAction === 'issueTypes') {
const getIssueTypesParams = subActionParams as ExecutorSubActionGetIssueTypesParams;
const getIssueTypesParams = subActionParams as ExecutorSubActionCommonFieldsParams;
data = await api.issueTypes({
externalService,
params: getIssueTypesParams,

View file

@ -25,14 +25,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
ExternalIncidentServiceSecretConfiguration
);
export const ExecutorSubActionSchema = schema.oneOf([
schema.literal('getIncident'),
schema.literal('pushToService'),
schema.literal('handshake'),
schema.literal('issueTypes'),
schema.literal('fieldsByIssueType'),
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
incident: schema.object({
summary: schema.string(),

View file

@ -155,12 +155,12 @@ describe('Jira service', () => {
).toThrow();
});
test('throws without username', () => {
test('throws without email/username', () => {
expect(() =>
createExternalService(
{
config: { apiUrl: 'test.com' },
secrets: { apiToken: '', email: 'elastic@elastic.com' },
config: { apiUrl: 'test.com', projectKey: 'CK' },
secrets: { apiToken: 'token' },
},
logger,
configurationUtilities
@ -168,12 +168,12 @@ describe('Jira service', () => {
).toThrow();
});
test('throws without password', () => {
test('throws without apiToken/password', () => {
expect(() =>
createExternalService(
{
config: { apiUrl: 'test.com' },
secrets: { apiToken: '', email: undefined },
config: { apiUrl: 'test.com', projectKey: 'CK' },
secrets: { email: 'elastic@elastic.com' },
},
logger,
configurationUtilities

View file

@ -16,10 +16,10 @@ import {
ExecutorSubActionGetIncidentParamsSchema,
ExecutorSubActionHandshakeParamsSchema,
ExecutorSubActionGetCapabilitiesParamsSchema,
ExecutorSubActionGetIssueTypesParamsSchema,
ExecutorSubActionGetFieldsByIssueTypeParamsSchema,
ExecutorSubActionGetIssuesParamsSchema,
ExecutorSubActionGetIssueParamsSchema,
ExecutorSubActionCommonFieldsParamsSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { Logger } from '../../../../../../src/core/server';
@ -124,8 +124,8 @@ export type ExecutorSubActionGetCapabilitiesParams = TypeOf<
typeof ExecutorSubActionGetCapabilitiesParamsSchema
>;
export type ExecutorSubActionGetIssueTypesParams = TypeOf<
typeof ExecutorSubActionGetIssueTypesParamsSchema
export type ExecutorSubActionCommonFieldsParams = TypeOf<
typeof ExecutorSubActionCommonFieldsParamsSchema
>;
export type ExecutorSubActionGetFieldsByIssueTypeParams = TypeOf<
@ -157,12 +157,12 @@ export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs {
export interface GetIssueTypesHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionGetIssueTypesParams;
params: ExecutorSubActionCommonFieldsParams;
}
export interface GetCommonFieldsHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionGetIssueTypesParams;
params: ExecutorSubActionCommonFieldsParams;
}
export interface GetFieldsByIssueTypeHandlerArgs {

View file

@ -25,14 +25,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
ExternalIncidentServiceSecretConfiguration
);
export const ExecutorSubActionSchema = schema.oneOf([
schema.literal('getIncident'),
schema.literal('pushToService'),
schema.literal('handshake'),
schema.literal('incidentTypes'),
schema.literal('severity'),
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
incident: schema.object({
name: schema.string(),

View file

@ -24,14 +24,6 @@ export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
ExternalIncidentServiceSecretConfiguration
);
export const ExecutorSubActionSchema = schema.oneOf([
schema.literal('getFields'),
schema.literal('getIncident'),
schema.literal('pushToService'),
schema.literal('handshake'),
schema.literal('getChoices'),
]);
const CommentsSchema = schema.nullable(
schema.arrayOf(
schema.object({

View file

@ -0,0 +1,142 @@
/*
* 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 { api } from './api';
import { ExternalService } from './types';
import {
apiParams,
externalServiceMock,
recordResponseCreate,
recordResponseUpdate,
} from './mocks';
import { Logger } from '@kbn/logging';
let mockedLogger: jest.Mocked<Logger>;
describe('api', () => {
let externalService: jest.Mocked<ExternalService>;
beforeEach(() => {
externalService = externalServiceMock.create();
});
describe('pushToService', () => {
test('it pushes a new record', async () => {
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
const res = await api.pushToService({
externalService,
logger: mockedLogger,
params,
});
expect(externalService.createComment).toHaveBeenCalled();
expect(externalService.createRecord).toHaveBeenCalled();
expect(externalService.updateRecord).not.toHaveBeenCalled();
expect(res).toEqual({
...recordResponseCreate,
comments: [
{
commentId: '123456',
pushedDate: '2021-06-01T17:29:51.092Z',
},
{
commentId: '123456',
pushedDate: '2021-06-01T17:29:51.092Z',
},
],
});
});
test('it pushes a new record without comment', async () => {
const params = {
...apiParams,
incident: { ...apiParams.incident, externalId: null },
comments: [],
};
const res = await api.pushToService({
externalService,
logger: mockedLogger,
params,
});
expect(externalService.createComment).not.toHaveBeenCalled();
expect(externalService.createRecord).toHaveBeenCalled();
expect(res).toEqual(recordResponseCreate);
});
test('updates existing record', async () => {
const res = await api.pushToService({
externalService,
logger: mockedLogger,
params: apiParams,
});
expect(externalService.createComment).toHaveBeenCalled();
expect(externalService.createRecord).not.toHaveBeenCalled();
expect(externalService.updateRecord).toHaveBeenCalled();
expect(res).toEqual({
...recordResponseUpdate,
comments: [
{
commentId: '123456',
pushedDate: '2021-06-01T17:29:51.092Z',
},
{
commentId: '123456',
pushedDate: '2021-06-01T17:29:51.092Z',
},
],
});
});
test('it calls createRecord correctly', async () => {
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createRecord).toHaveBeenCalledWith({
incident: {
alertId: '123456',
caseId: '123456',
caseName: 'case name',
description: 'case desc',
ruleName: 'rule name',
severity: 'critical',
},
});
});
test('it calls createComment correctly', async () => {
const mockedToISOString = jest
.spyOn(Date.prototype, 'toISOString')
.mockReturnValue('2021-06-15T18:02:29.404Z');
const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } };
await api.pushToService({ externalService, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
createdDate: '2021-06-15T18:02:29.404Z',
incidentId: '123456',
comment: {
commentId: 'case-comment-1',
comment: 'A comment',
},
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
createdDate: '2021-06-15T18:02:29.404Z',
incidentId: '123456',
comment: {
commentId: 'case-comment-2',
comment: 'Another comment',
},
});
mockedToISOString.mockRestore();
});
});
});

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 {
ExternalServiceIncidentResponse,
ExternalServiceApi,
Incident,
PushToServiceApiHandlerArgs,
PushToServiceResponse,
} from './types';
const pushToServiceHandler = async ({
externalService,
params,
}: PushToServiceApiHandlerArgs): Promise<ExternalServiceIncidentResponse> => {
const { comments } = params;
let res: PushToServiceResponse;
const { externalId, ...rest } = params.incident;
const incident: Incident = rest;
if (externalId != null) {
res = await externalService.updateRecord({
incidentId: externalId,
incident,
});
} else {
res = await externalService.createRecord({ incident });
}
const createdDate = new Date().toISOString();
if (comments && Array.isArray(comments) && comments.length > 0) {
res.comments = [];
for (const currentComment of comments) {
const comment = await externalService.createComment({
incidentId: res.id,
comment: currentComment,
createdDate,
});
res.comments = [
...(res.comments ?? []),
{
commentId: comment.commentId,
pushedDate: comment.pushedDate,
},
];
}
}
return res;
};
export const api: ExternalServiceApi = {
pushToService: pushToServiceHandler,
};

View file

@ -0,0 +1,90 @@
/*
* 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 { getBodyForEventAction } from './helpers';
import { mappings } from './mocks';
describe('Create Record Mapping', () => {
const appId = '45678';
test('it maps successfully', () => {
const params = {
alertId: 'al123',
ruleName: 'Rule Name',
severity: 'Critical',
caseName: 'Case Name',
caseId: 'es3456789',
description: 'case desc',
externalId: null,
};
const data = getBodyForEventAction(appId, mappings, params);
expect(data.applicationId).toEqual(appId);
expect(data.id).not.toBeDefined();
expect(data.values?.[mappings.alertIdConfig?.id ?? 0]).toEqual(params.alertId);
expect(data.values?.[mappings.ruleNameConfig.id]).toEqual(params.ruleName);
expect(data.values?.[mappings.caseNameConfig?.id ?? 0]).toEqual(params.caseName);
expect(data.values?.[mappings.caseIdConfig?.id ?? 0]).toEqual(params.caseId);
expect(data.values?.[mappings?.severityConfig?.id ?? 0]).toEqual(params.severity);
expect(data.values?.[mappings?.descriptionConfig?.id ?? 0]).toEqual(params.description);
});
test('it contains the id if defined', () => {
const params = {
alertId: 'al123',
ruleName: 'Rule Name',
severity: 'Critical',
caseName: 'Case Name',
caseId: 'es3456789',
description: 'case desc',
externalId: null,
};
const data = getBodyForEventAction(appId, mappings, params, '123');
expect(data.id).toEqual('123');
});
test('it does not includes null mappings', () => {
const params = {
alertId: 'al123',
ruleName: 'Rule Name',
severity: 'Critical',
caseName: 'Case Name',
caseId: 'es3456789',
description: 'case desc',
externalId: null,
};
// @ts-expect-error
const data = getBodyForEventAction(appId, { ...mappings, test: null }, params);
expect(data.values?.test).not.toBeDefined();
});
test('it converts a numeric values correctly', () => {
const params = {
alertId: 'thisIsNotANumber',
ruleName: 'Rule Name',
severity: 'Critical',
caseName: 'Case Name',
caseId: '123',
description: 'case desc',
externalId: null,
};
const data = getBodyForEventAction(
appId,
{
...mappings,
caseIdConfig: { ...mappings.caseIdConfig, fieldType: 'numeric' },
alertIdConfig: { ...mappings.alertIdConfig, fieldType: 'numeric' },
},
params
);
expect(data.values?.[mappings.alertIdConfig?.id ?? 0]).toBe(0);
expect(data.values?.[mappings.caseIdConfig?.id ?? 0]).toBe(123);
});
});

View file

@ -0,0 +1,58 @@
/*
* 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 { CreateRecordParams, Incident, SwimlaneRecordPayload, MappingConfigType } from './types';
type ConfigMapping = Omit<MappingConfigType, 'commentsConfig'>;
const mappingKeysToIncidentKeys: Record<keyof ConfigMapping, keyof Incident> = {
ruleNameConfig: 'ruleName',
alertIdConfig: 'alertId',
caseIdConfig: 'caseId',
caseNameConfig: 'caseName',
severityConfig: 'severity',
descriptionConfig: 'description',
};
export const getBodyForEventAction = (
applicationId: string,
mappingConfig: MappingConfigType,
params: CreateRecordParams['incident'],
incidentId?: string
): SwimlaneRecordPayload => {
const data: SwimlaneRecordPayload = {
applicationId,
...(incidentId ? { id: incidentId } : {}),
values: {},
};
return (Object.keys(mappingConfig) as Array<keyof ConfigMapping>).reduce((acc, key) => {
const fieldMap = mappingConfig[key];
if (!fieldMap) {
return acc;
}
const { id, fieldType } = fieldMap;
const paramName = mappingKeysToIncidentKeys[key];
const value = params[paramName];
if (value) {
switch (fieldType) {
case 'numeric': {
const number = Number(value);
return { ...acc, values: { ...acc.values, [id]: isNaN(number) ? 0 : number } };
}
default: {
return { ...acc, values: { ...acc.values, [id]: value } };
}
}
}
return acc;
}, data);
};

View file

@ -0,0 +1,116 @@
/*
* 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 { curry } from 'lodash';
import { i18n } from '@kbn/i18n';
import { schema } from '@kbn/config-schema';
import { Logger } from '@kbn/logging';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
import { ActionsConfigurationUtilities } from '../../actions_config';
import {
SwimlaneExecutorResultData,
SwimlanePublicConfigurationType,
SwimlaneSecretConfigurationType,
ExecutorParams,
ExecutorSubActionPushParams,
} from './types';
import { validate } from './validators';
import {
ExecutorParamsSchema,
SwimlaneSecretsConfiguration,
SwimlaneServiceConfiguration,
} from './schema';
import { createExternalService } from './service';
import { api } from './api';
interface GetActionTypeParams {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
}
const supportedSubActions: string[] = ['pushToService'];
// action type definition
export function getActionType(
params: GetActionTypeParams
): ActionType<
SwimlanePublicConfigurationType,
SwimlaneSecretConfigurationType,
ExecutorParams,
SwimlaneExecutorResultData | {}
> {
const { logger, configurationUtilities } = params;
return {
id: '.swimlane',
minimumLicenseRequired: 'gold',
name: i18n.translate('xpack.actions.builtin.swimlaneTitle', {
defaultMessage: 'Swimlane',
}),
validate: {
config: schema.object(SwimlaneServiceConfiguration, {
validate: curry(validate.config)(configurationUtilities),
}),
secrets: schema.object(SwimlaneSecretsConfiguration, {
validate: curry(validate.secrets)(configurationUtilities),
}),
params: ExecutorParamsSchema,
},
executor: curry(executor)({ logger, configurationUtilities }),
};
}
async function executor(
{
logger,
configurationUtilities,
}: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities },
execOptions: ActionTypeExecutorOptions<
SwimlanePublicConfigurationType,
SwimlaneSecretConfigurationType,
ExecutorParams
>
): Promise<ActionTypeExecutorResult<SwimlaneExecutorResultData | {}>> {
const { actionId, config, params, secrets } = execOptions;
const { subAction, subActionParams } = params as ExecutorParams;
let data: SwimlaneExecutorResultData | null = null;
const externalService = createExternalService(
{
config,
secrets,
},
logger,
configurationUtilities
);
if (!api[subAction]) {
const errorMessage = `[Action][ExternalService] -> [Swimlane] Unsupported subAction type ${subAction}.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (!supportedSubActions.includes(subAction)) {
const errorMessage = `[Action][ExternalService] -> [Swimlane] subAction ${subAction} not implemented.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (subAction === 'pushToService') {
const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
data = await api.pushToService({
externalService,
params: pushToServiceParams,
logger,
});
logger.debug(`response push to service for incident id: ${data.id}`);
}
return { status: 'ok', data: data ?? {}, actionId };
}

View file

@ -0,0 +1,124 @@
/*
* 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 { ExecutorSubActionPushParams, ExternalService, PushToServiceApiParams } from './types';
export const applicationFields = [
{
id: 'adnlas',
name: 'Severity',
key: 'severity',
fieldType: 'text',
},
{
id: 'adnfls',
name: 'Rule Name',
key: 'rule-name',
fieldType: 'text',
},
{
id: 'a6sst',
name: 'Case Id',
key: 'case-id-name',
fieldType: 'text',
},
{
id: 'a6fst',
name: 'Case Name',
key: 'case-name',
fieldType: 'text',
},
{
id: 'a6fdf',
name: 'Comments',
key: 'comments',
fieldType: 'notes',
},
{
id: 'a6fde',
name: 'Description',
key: 'description',
fieldType: 'text',
},
{
id: 'dfnkls',
name: 'Alert ID',
key: 'alert-id',
fieldType: 'text',
},
];
export const mappings = {
severityConfig: applicationFields[0],
ruleNameConfig: applicationFields[1],
caseIdConfig: applicationFields[2],
caseNameConfig: applicationFields[3],
commentsConfig: applicationFields[4],
descriptionConfig: applicationFields[5],
alertIdConfig: applicationFields[6],
};
export const getApplicationResponse = { fields: applicationFields };
export const recordResponseCreate = {
id: '123456',
title: 'neato',
url: 'swimlane.com',
pushedDate: '2021-06-01T17:29:51.092Z',
};
export const recordResponseUpdate = {
id: '98765',
title: 'not neato',
url: 'laneswim.com',
pushedDate: '2021-06-01T17:29:51.092Z',
};
export const commentResponse = {
commentId: '123456',
pushedDate: '2021-06-01T17:29:51.092Z',
};
const createMock = (): jest.Mocked<ExternalService> => {
return {
createComment: jest.fn().mockImplementation(() => Promise.resolve(commentResponse)),
createRecord: jest.fn().mockImplementation(() => Promise.resolve(recordResponseCreate)),
updateRecord: jest.fn().mockImplementation(() => Promise.resolve(recordResponseUpdate)),
};
};
const externalServiceMock = {
create: createMock,
};
const executorParams: ExecutorSubActionPushParams = {
incident: {
ruleName: 'rule name',
alertId: '123456',
caseName: 'case name',
severity: 'critical',
caseId: '123456',
description: 'case desc',
externalId: 'incident-3',
},
comments: [
{
commentId: 'case-comment-1',
comment: 'A comment',
},
{
commentId: 'case-comment-2',
comment: 'Another comment',
},
],
};
const apiParams: PushToServiceApiParams = {
...executorParams,
};
export { externalServiceMock, executorParams, apiParams };

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema } from '@kbn/config-schema';
export const ConfigMap = {
id: schema.string(),
key: schema.string(),
name: schema.string(),
fieldType: schema.string(),
};
export const ConfigMapSchema = schema.object(ConfigMap);
export const ConfigMapping = {
ruleNameConfig: schema.nullable(ConfigMapSchema),
alertIdConfig: schema.nullable(ConfigMapSchema),
caseIdConfig: schema.nullable(ConfigMapSchema),
caseNameConfig: schema.nullable(ConfigMapSchema),
commentsConfig: schema.nullable(ConfigMapSchema),
severityConfig: schema.nullable(ConfigMapSchema),
descriptionConfig: schema.nullable(ConfigMapSchema),
};
export const ConfigMappingSchema = schema.object(ConfigMapping);
export const SwimlaneServiceConfiguration = {
apiUrl: schema.string(),
appId: schema.string(),
connectorType: schema.string(),
mappings: ConfigMappingSchema,
};
export const SwimlaneServiceConfigurationSchema = schema.object(SwimlaneServiceConfiguration);
export const SwimlaneSecretsConfiguration = {
apiToken: schema.string(),
};
export const SwimlaneSecretsConfigurationSchema = schema.object(SwimlaneSecretsConfiguration);
const SwimlaneFields = {
alertId: schema.nullable(schema.string()),
ruleName: schema.nullable(schema.string()),
caseId: schema.nullable(schema.string()),
caseName: schema.nullable(schema.string()),
severity: schema.nullable(schema.string()),
description: schema.nullable(schema.string()),
};
export const ExecutorSubActionPushParamsSchema = schema.object({
incident: schema.object({
...SwimlaneFields,
externalId: schema.nullable(schema.string()),
}),
comments: schema.nullable(
schema.arrayOf(
schema.object({
comment: schema.string(),
commentId: schema.string(),
})
)
),
});
export const ExecutorParamsSchema = schema.oneOf([
schema.object({
subAction: schema.literal('pushToService'),
subActionParams: ExecutorSubActionPushParamsSchema,
}),
]);

View file

@ -0,0 +1,434 @@
/*
* 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 from 'axios';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { Logger } from '../../../../../../src/core/server';
import { actionsConfigMock } from '../../actions_config.mock';
import * as utils from '../lib/axios_utils';
import { createExternalService } from './service';
import { mappings } from './mocks';
import { ExternalService } from './types';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
jest.mock('../lib/axios_utils', () => {
const originalUtils = jest.requireActual('../lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
};
});
axios.create = jest.fn(() => axios);
const requestMock = utils.request as jest.Mock;
const configurationUtilities = actionsConfigMock.create();
describe('Swimlane Service', () => {
let service: ExternalService;
const config = {
apiUrl: 'https://test.swimlane.com/',
appId: 'bcq16kdTbz5jlwM6h',
connectorType: 'all',
mappings,
};
const apiToken = 'token';
const headers = {
'Content-Type': 'application/json',
'Private-Token': apiToken,
};
const incident = {
ruleName: 'Rule Name',
caseId: 'Case Id',
caseName: 'Case Name',
severity: 'Severity',
externalId: null,
description: 'Description',
alertId: 'Alert Id',
};
const url = config.apiUrl.slice(0, -1);
beforeAll(() => {
service = createExternalService(
{
// The trailing slash at the end of the url is intended.
// All API calls need to have the trailing slash removed.
config,
secrets: { apiToken },
},
logger,
configurationUtilities
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('createExternalService', () => {
test('throws without url', () => {
expect(() =>
createExternalService(
{
config: {
// @ts-ignore
apiUrl: null,
appId: '99999',
mappings,
},
secrets: { apiToken },
},
logger,
configurationUtilities
)
).toThrow();
});
test('throws without app id', () => {
expect(() =>
createExternalService(
{
config: {
apiUrl: 'test.com',
// @ts-ignore
appId: null,
},
secrets: { apiToken },
},
logger,
configurationUtilities
)
).toThrow();
});
test('throws without mappings', () => {
expect(() =>
createExternalService(
{
config: {
apiUrl: 'test.com',
appId: '987987',
// @ts-ignore
mappings: null,
},
secrets: { apiToken },
},
logger,
configurationUtilities
)
).toThrow();
});
test('throws without api token', () => {
expect(() => {
return createExternalService(
{
config: { apiUrl: 'test.com', appId: '78978', mappings, connectorType: 'all' },
secrets: {
// @ts-ignore
apiToken: null,
},
},
logger,
configurationUtilities
);
}).toThrow();
});
});
describe('createRecord', () => {
const data = {
id: '123',
name: 'title',
createdDate: '2021-06-01T17:29:51.092Z',
};
test('it creates a record correctly', async () => {
requestMock.mockImplementation(() => ({
data,
}));
const res = await service.createRecord({
incident,
});
expect(res).toEqual({
id: '123',
title: 'title',
pushedDate: '2021-06-01T17:29:51.092Z',
url: `${url}/record/${config.appId}/123`,
});
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({
data,
}));
await service.createRecord({
incident,
});
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
headers,
data: {
applicationId: config.appId,
values: {
[mappings.ruleNameConfig.id]: 'Rule Name',
[mappings.caseNameConfig.id]: 'Case Name',
[mappings.caseIdConfig.id]: 'Case Id',
[mappings.severityConfig.id]: 'Severity',
[mappings.descriptionConfig.id]: 'Description',
[mappings.alertIdConfig.id]: 'Alert Id',
},
},
url: `${url}/api/app/${config.appId}/record`,
method: 'post',
configurationUtilities,
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(service.createRecord({ incident })).rejects.toThrow(
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
);
});
});
describe('updateRecord', () => {
const data = {
id: '123',
name: 'title',
modifiedDate: '2021-06-01T17:29:51.092Z',
};
const incidentId = '123';
test('it updates a record correctly', async () => {
requestMock.mockImplementation(() => ({
data,
}));
const res = await service.updateRecord({
incident,
incidentId,
});
expect(res).toEqual({
id: '123',
title: 'title',
pushedDate: '2021-06-01T17:29:51.092Z',
url: `${url}/record/${config.appId}/123`,
});
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({
data,
}));
await service.updateRecord({
incident,
incidentId,
});
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
headers,
data: {
applicationId: config.appId,
id: incidentId,
values: {
[mappings.ruleNameConfig.id]: 'Rule Name',
[mappings.caseNameConfig.id]: 'Case Name',
[mappings.caseIdConfig.id]: 'Case Id',
[mappings.severityConfig.id]: 'Severity',
[mappings.descriptionConfig.id]: 'Description',
[mappings.alertIdConfig.id]: 'Alert Id',
},
},
url: `${url}/api/app/${config.appId}/record/${incidentId}`,
method: 'patch',
configurationUtilities,
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(service.updateRecord({ incident, incidentId })).rejects.toThrow(
`[Action][Swimlane]: Unable to update record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
);
});
});
describe('createComment', () => {
const data = {
id: '123',
name: 'title',
modifiedDate: '2021-06-01T17:29:51.092Z',
};
const incidentId = '123';
const comment = { commentId: '456', comment: 'A comment' };
const createdDate = '2021-06-01T17:29:51.092Z';
test('it updates a record correctly', async () => {
requestMock.mockImplementation(() => ({
data,
}));
const res = await service.createComment({
comment,
incidentId,
createdDate,
});
expect(res).toEqual({
commentId: '456',
pushedDate: '2021-06-01T17:29:51.092Z',
});
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({
data,
}));
await service.createComment({
comment,
incidentId,
createdDate,
});
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
headers,
data: {
createdDate,
fieldId: mappings.commentsConfig.id,
isRichText: true,
message: comment.comment,
},
url: `${url}/api/app/${config.appId}/record/${incidentId}/${mappings.commentsConfig.id}/comment`,
method: 'post',
configurationUtilities,
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(service.createComment({ comment, incidentId, createdDate })).rejects.toThrow(
`[Action][Swimlane]: Unable to create comment in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
);
});
});
describe('error messages', () => {
const errorResponse = { ErrorCode: '1', Argument: 'Invalid field' };
test('it contains the response error', async () => {
requestMock.mockImplementation(() => {
const error = new Error('An error has occurred');
// @ts-ignore
error.response = { data: errorResponse };
throw error;
});
await expect(
service.createRecord({
incident,
})
).rejects.toThrow(
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: Invalid field (1)`
);
});
test('it shows an empty string for reason if the ErrorCode is undefined', async () => {
requestMock.mockImplementation(() => {
const error = new Error('An error has occurred');
// @ts-ignore
error.response = { data: { ErrorCode: '1' } };
throw error;
});
await expect(
service.createRecord({
incident,
})
).rejects.toThrow(
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
);
});
test('it shows an empty string for reason if the Argument is undefined', async () => {
requestMock.mockImplementation(() => {
const error = new Error('An error has occurred');
// @ts-ignore
error.response = { data: { Argument: 'Invalid field' } };
throw error;
});
await expect(
service.createRecord({
incident,
})
).rejects.toThrow(
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
);
});
test('it shows an empty string for reason if data is undefined', async () => {
requestMock.mockImplementation(() => {
const error = new Error('An error has occurred');
// @ts-ignore
error.response = {};
throw error;
});
await expect(
service.createRecord({
incident,
})
).rejects.toThrow(
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 500. Error: An error has occurred. Reason: unknown`
);
});
test('it shows the status code', async () => {
requestMock.mockImplementation(() => {
const error = new Error('An error has occurred');
// @ts-ignore
error.response = { data: errorResponse, status: 400 };
throw error;
});
await expect(
service.createRecord({
incident,
})
).rejects.toThrow(
`[Action][Swimlane]: Unable to create record in application with id ${config.appId}. Status: 400. Error: An error has occurred. Reason: Invalid field (1)`
);
});
});
});

View file

@ -0,0 +1,196 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Logger } from '@kbn/logging';
import axios from 'axios';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { getErrorMessage, request } from '../lib/axios_utils';
import { getBodyForEventAction } from './helpers';
import {
CreateCommentParams,
CreateRecordParams,
ExternalService,
ExternalServiceCredentials,
ExternalServiceIncidentResponse,
MappingConfigType,
ResponseError,
SwimlanePublicConfigurationType,
SwimlaneRecordPayload,
SwimlaneSecretConfigurationType,
UpdateRecordParams,
} from './types';
import * as i18n from './translations';
const createErrorMessage = (errorResponse: ResponseError | null | undefined): string => {
if (errorResponse == null) {
return 'unknown';
}
const { ErrorCode, Argument } = errorResponse;
return Argument != null && ErrorCode != null ? `${Argument} (${ErrorCode})` : 'unknown';
};
export const createExternalService = (
{ config, secrets }: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities
): ExternalService => {
const { apiUrl: url, appId, mappings } = config as SwimlanePublicConfigurationType;
const { apiToken } = secrets as SwimlaneSecretConfigurationType;
const axiosInstance = axios.create();
if (!url || !appId || !apiToken || !mappings) {
throw Error(`[Action]${i18n.NAME}: Wrong configuration.`);
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Private-Token': `${secrets.apiToken}`,
};
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
const apiUrl = urlWithoutTrailingSlash.endsWith('api')
? urlWithoutTrailingSlash
: urlWithoutTrailingSlash + '/api';
const getPostRecordUrl = (id: string) => `${apiUrl}/app/${id}/record`;
const getPostRecordIdUrl = (id: string, recordId: string) =>
`${getPostRecordUrl(id)}/${recordId}`;
const getRecordIdUrl = (id: string, recordId: string) =>
`${urlWithoutTrailingSlash}/record/${id}/${recordId}`;
const getPostCommentUrl = (id: string, recordId: string, commentFieldId: string) =>
`${getPostRecordIdUrl(id, recordId)}/${commentFieldId}/comment`;
const getCommentFieldId = (fieldMappings: MappingConfigType): string | null =>
fieldMappings.commentsConfig?.id || null;
const createRecord = async (
params: CreateRecordParams
): Promise<ExternalServiceIncidentResponse> => {
try {
const mappingConfig = mappings as MappingConfigType;
const data = getBodyForEventAction(appId, mappingConfig, params.incident);
const res = await request({
axios: axiosInstance,
configurationUtilities,
data,
headers,
logger,
method: 'post',
url: getPostRecordUrl(appId),
});
return {
id: res.data.id,
title: res.data.name,
url: getRecordIdUrl(appId, res.data.id),
pushedDate: new Date(res.data.createdDate).toISOString(),
};
} catch (error) {
throw new Error(
getErrorMessage(
i18n.NAME,
`Unable to create record in application with id ${appId}. Status: ${
error.response?.status ?? 500
}. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}`
)
);
}
};
const updateRecord = async (
params: UpdateRecordParams
): Promise<ExternalServiceIncidentResponse> => {
try {
const mappingConfig = mappings as MappingConfigType;
const data = getBodyForEventAction(appId, mappingConfig, params.incident, params.incidentId);
const res = await request<SwimlaneRecordPayload>({
axios: axiosInstance,
configurationUtilities,
data,
headers,
logger,
method: 'patch',
url: getPostRecordIdUrl(appId, params.incidentId),
});
return {
id: res.data.id,
title: res.data.name,
url: getRecordIdUrl(appId, params.incidentId),
pushedDate: new Date(res.data.modifiedDate).toISOString(),
};
} catch (error) {
throw new Error(
getErrorMessage(
i18n.NAME,
`Unable to update record in application with id ${appId}. Status: ${
error.response?.status ?? 500
}. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}`
)
);
}
};
const createComment = async ({ incidentId, comment, createdDate }: CreateCommentParams) => {
try {
const mappingConfig = mappings as MappingConfigType;
const fieldId = getCommentFieldId(mappingConfig);
if (fieldId == null) {
throw new Error(`No comment field mapped in ${i18n.NAME} connector`);
}
const data = {
createdDate,
fieldId,
isRichText: true,
message: comment.comment,
};
await request({
axios: axiosInstance,
configurationUtilities,
data,
headers,
logger,
method: 'post',
url: getPostCommentUrl(appId, incidentId, fieldId),
});
/**
* Swimlane response does not contain any data.
* We cannot get an externalCommentId
*/
return {
commentId: comment.commentId,
pushedDate: createdDate,
};
} catch (error) {
throw new Error(
getErrorMessage(
i18n.NAME,
`Unable to create comment in application with id ${appId}. Status: ${
error.response?.status ?? 500
}. Error: ${error.message}. Reason: ${createErrorMessage(error.response?.data)}`
)
);
}
};
return {
createComment,
createRecord,
updateRecord,
};
};

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const NAME = i18n.translate('xpack.actions.builtin.case.swimlaneTitle', {
defaultMessage: 'Swimlane',
});
export const ALLOWED_HOSTS_ERROR = (message: string) =>
i18n.translate('xpack.actions.builtin.swimlane.configuration.apiAllowedHostsError', {
defaultMessage: 'error configuring connector action: {message}',
values: {
message,
},
});

View file

@ -0,0 +1,123 @@
/*
* 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.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TypeOf } from '@kbn/config-schema';
import { Logger } from '@kbn/logging';
import {
ConfigMappingSchema,
ExecutorParamsSchema,
ExecutorSubActionPushParamsSchema,
SwimlaneSecretsConfigurationSchema,
SwimlaneServiceConfigurationSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
export type SwimlanePublicConfigurationType = TypeOf<typeof SwimlaneServiceConfigurationSchema>;
export type SwimlaneSecretConfigurationType = TypeOf<typeof SwimlaneSecretsConfigurationSchema>;
export type MappingConfigType = TypeOf<typeof ConfigMappingSchema>;
export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>;
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
export interface ExternalServiceCredentials {
config: SwimlanePublicConfigurationType;
secrets: SwimlaneSecretConfigurationType;
}
export interface ExternalServiceValidation {
config: (configurationUtilities: ActionsConfigurationUtilities, configObject: any) => void;
secrets: (configurationUtilities: ActionsConfigurationUtilities, secrets: any) => void;
}
export interface CreateRecordParams {
incident: Incident;
}
export interface UpdateRecordParams extends CreateRecordParams {
incidentId: string;
}
export type PushToServiceApiParams = ExecutorSubActionPushParams;
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: PushToServiceApiParams;
logger: Logger;
}
export interface ExternalServiceIncidentResponse {
id: string;
title: string;
url: string;
pushedDate: string;
}
export interface ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}
export interface FieldConfig {
id: string;
name: string;
key: string;
fieldType: string;
}
export interface SwimlaneRecordPayload {
applicationId: string;
values: SwimlaneDataValues;
id?: string;
}
export interface ExternalService {
createComment: (params: CreateCommentParams) => Promise<ExternalServiceCommentResponse>;
createRecord: (params: CreateRecordParams) => Promise<ExternalServiceIncidentResponse>;
updateRecord: (params: UpdateRecordParams) => Promise<ExternalServiceIncidentResponse>;
}
export type Incident = Omit<ExecutorSubActionPushParams['incident'], 'externalId'>;
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
}
export interface GetApplicationHandlerArgs {
externalService: ExternalService;
}
export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
comments?: ExternalServiceCommentResponse[];
}
export interface ExternalServiceApi {
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<ExternalServiceIncidentResponse>;
}
export type SwimlaneExecutorResultData = ExternalServiceIncidentResponse;
export type SwimlaneDataValues = Record<string, string | number>;
export interface SwimlaneComment {
fieldId: string;
message: string | number;
createdDate: string;
isRichText: boolean;
}
export type SwimlaneDataComments = Record<string, SwimlaneComment[]>;
export interface SimpleComment {
comment: SwimlaneComment['message'];
commentId: string;
}
export interface CreateCommentParams {
incidentId: string;
comment: SimpleComment;
createdDate: string;
}
export interface ResponseError {
ErrorCode: number;
Argument: string;
}

View file

@ -0,0 +1,28 @@
/*
* 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 { ActionsConfigurationUtilities } from '../../actions_config';
import { ExternalServiceValidation, SwimlanePublicConfigurationType } from './types';
import * as i18n from './translations';
export const validateCommonConfig = (
configurationUtilities: ActionsConfigurationUtilities,
configObject: SwimlanePublicConfigurationType
) => {
try {
configurationUtilities.ensureUriAllowed(configObject.apiUrl);
} catch (allowedListError) {
return i18n.ALLOWED_HOSTS_ERROR(allowedListError.message);
}
};
export const validateCommonSecrets = () => {};
export const validate: ExternalServiceValidation = {
config: validateCommonConfig,
secrets: validateCommonSecrets,
};

View file

@ -47,7 +47,6 @@ export type {
TeamsActionTypeId,
TeamsActionParams,
} from './builtin_action_types';
export type { PluginSetupContract, PluginStartContract } from './plugin';
export { asSavedObjectExecutionSource, asHttpRequestExecutionSource } from './lib';

View file

@ -22,7 +22,7 @@ export { ActionTypeExecutorResult } from '../common';
export { GetFieldsByIssueTypeResponse as JiraGetFieldsResponse } from './builtin_action_types/jira/types';
export { GetCommonFieldsResponse as ServiceNowGetFieldsResponse } from './builtin_action_types/servicenow/types';
export { GetCommonFieldsResponse as ResilientGetFieldsResponse } from './builtin_action_types/resilient/types';
export { SwimlanePublicConfigurationType } from './builtin_action_types/swimlane/types';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
export type GetServicesFunction = (request: KibanaRequest) => Services;
export type ActionTypeRegistryContract = PublicMethodsOf<ActionTypeRegistry>;

View file

@ -18,6 +18,7 @@ const byTypeSchema: MakeSchemaFrom<ActionsUsage>['count_by_type'] = {
__email: { type: 'long' },
__index: { type: 'long' },
__pagerduty: { type: 'long' },
__swimlane: { type: 'long' },
'__server-log': { type: 'long' },
__slack: { type: 'long' },
__webhook: { type: 'long' },

View file

@ -215,7 +215,7 @@ This action type has no `secrets` properties.
| -------- | ------------------------------------------------------------------------------------------------- | ----------------- |
| id | ID of the connector used for pushing case updates to external systems. | string |
| name | The connector name. | string |
| type | The type of the connector. Must be one of these: `.servicenow`, `jira`, `.resilient`, and `.none` | string |
| type | The type of the connector. Must be one of these: `.servicenow`, `.servicenow-sir`, `.swimlane`, `jira`, `.resilient`, and `.none` | string |
| fields | Object containing the connectors fields. | [fields](#fields) |
#### `fields`

View file

@ -12,12 +12,14 @@ import { JiraFieldsRT } from './jira';
import { ResilientFieldsRT } from './resilient';
import { ServiceNowITSMFieldsRT } from './servicenow_itsm';
import { ServiceNowSIRFieldsRT } from './servicenow_sir';
import { SwimlaneFieldsRT } from './swimlane';
export * from './jira';
export * from './servicenow_itsm';
export * from './servicenow_sir';
export * from './resilient';
export * from './mappings';
export * from './swimlane';
export type ActionConnector = ActionResult;
export type ActionTypeConnector = ActionType;
@ -32,10 +34,11 @@ export const ConnectorFieldsRt = rt.union([
export enum ConnectorTypes {
jira = '.jira',
none = '.none',
resilient = '.resilient',
serviceNowITSM = '.servicenow',
serviceNowSIR = '.servicenow-sir',
none = '.none',
swimlane = '.swimlane',
}
export const connectorTypes = Object.values(ConnectorTypes);
@ -55,6 +58,11 @@ const ConnectorServiceNowITSMTypeFieldsRt = rt.type({
fields: rt.union([ServiceNowITSMFieldsRT, rt.null]),
});
const ConnectorSwimlaneTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.swimlane),
fields: rt.union([SwimlaneFieldsRT, rt.null]),
});
const ConnectorServiceNowSIRTypeFieldsRt = rt.type({
type: rt.literal(ConnectorTypes.serviceNowSIR),
fields: rt.union([ServiceNowSIRFieldsRT, rt.null]),
@ -67,10 +75,11 @@ const ConnectorNoneTypeFieldsRt = rt.type({
export const ConnectorTypeFieldsRt = rt.union([
ConnectorJiraTypeFieldsRt,
ConnectorNoneTypeFieldsRt,
ConnectorResillientTypeFieldsRt,
ConnectorServiceNowITSMTypeFieldsRt,
ConnectorServiceNowSIRTypeFieldsRt,
ConnectorNoneTypeFieldsRt,
ConnectorSwimlaneTypeFieldsRt,
]);
export const CaseConnectorRt = rt.intersection([
@ -85,6 +94,7 @@ export type CaseConnector = rt.TypeOf<typeof CaseConnectorRt>;
export type ConnectorTypeFields = rt.TypeOf<typeof ConnectorTypeFieldsRt>;
export type ConnectorJiraTypeFields = rt.TypeOf<typeof ConnectorJiraTypeFieldsRt>;
export type ConnectorResillientTypeFields = rt.TypeOf<typeof ConnectorResillientTypeFieldsRt>;
export type ConnectorSwimlaneTypeFields = rt.TypeOf<typeof ConnectorSwimlaneTypeFieldsRt>;
export type ConnectorServiceNowITSMTypeFields = rt.TypeOf<
typeof ConnectorServiceNowITSMTypeFieldsRt
>;

View file

@ -48,9 +48,6 @@ const ConnectorFieldRt = rt.type({
export type ConnectorField = rt.TypeOf<typeof ConnectorFieldRt>;
const GetFieldsResponseRt = rt.type({
defaultMappings: rt.array(ConnectorMappingsAttributesRT),
fields: rt.array(ConnectorFieldRt),
});
const GetDefaultMappingsResponseRt = rt.array(ConnectorMappingsAttributesRT);
export type GetFieldsResponse = rt.TypeOf<typeof GetFieldsResponseRt>;
export type GetDefaultMappingsResponse = rt.TypeOf<typeof GetDefaultMappingsResponseRt>;

View file

@ -0,0 +1,21 @@
/*
* 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 * as rt from 'io-ts';
// New fields should also be added at: x-pack/plugins/cases/server/connectors/case/schema.ts
export const SwimlaneFieldsRT = rt.type({
caseId: rt.union([rt.string, rt.null]),
});
export enum SwimlaneConnectorType {
All = 'all',
Alerts = 'alerts',
Cases = 'cases',
}
export type SwimlaneFieldsType = rt.TypeOf<typeof SwimlaneFieldsRT>;

View file

@ -4,6 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ConnectorTypes } from './api';
export const DEFAULT_DATE_FORMAT = 'dateFormat';
export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz';
@ -59,16 +61,12 @@ export const CASE_DETAILS_ALERTS_URL = `${CASE_DETAILS_URL}/alerts`;
export const ACTION_URL = '/api/actions';
export const ACTION_TYPES_URL = '/api/actions/list_action_types';
export const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow';
export const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir';
export const JIRA_ACTION_TYPE_ID = '.jira';
export const RESILIENT_ACTION_TYPE_ID = '.resilient';
export const SUPPORTED_CONNECTORS = [
SERVICENOW_ITSM_ACTION_TYPE_ID,
SERVICENOW_SIR_ACTION_TYPE_ID,
JIRA_ACTION_TYPE_ID,
RESILIENT_ACTION_TYPE_ID,
`${ConnectorTypes.serviceNowITSM}`,
`${ConnectorTypes.serviceNowSIR}`,
`${ConnectorTypes.jira}`,
`${ConnectorTypes.resilient}`,
`${ConnectorTypes.swimlane}`,
];
/**

View file

@ -24,6 +24,8 @@ export {
ValidationError,
ValidationFunc,
VALIDATION_TYPES,
FieldConfig,
ValidationConfig,
} from '../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib';
export {
Field,

View file

@ -608,6 +608,7 @@ describe('CaseView ', () => {
).toBe(connectorName);
});
});
it('should update connector', async () => {
const wrapper = mount(
<TestProviders>
@ -628,15 +629,19 @@ describe('CaseView ', () => {
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
await waitFor(() => wrapper.update());
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy();
});
wrapper.find(`button[data-test-subj="edit-connectors-submit"]`).first().simulate('click');
await waitFor(() => {
const updateObject = updateCaseProperty.mock.calls[0][0];
wrapper.update();
expect(updateCaseProperty).toHaveBeenCalledTimes(1);
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('connector');
expect(updateObject.updateValue).toEqual({
id: 'resilient-2',

View file

@ -31,17 +31,14 @@ import { useGetCaseUserActions } from '../../containers/use_get_case_user_action
import { usePushToService } from '../use_push_to_service';
import { EditConnector } from '../edit_connector';
import { useConnectors } from '../../containers/configure/use_connectors';
import {
getConnectorById,
normalizeActionConnector,
getNoneConnector,
} from '../configure_cases/utils';
import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils';
import { StatusActionButton } from '../status/button';
import * as i18n from './translations';
import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context';
import { useTimelineContext } from '../timeline_context/use_timeline_context';
import { CasesNavigation } from '../links';
import { OwnerProvider } from '../owner_context';
import { getConnectorById } from '../utils';
import { DoesNotExist } from './does_not_exist';
const gutterTimeline = '70px'; // seems to be a timeline reference from the original file

View file

@ -24,15 +24,11 @@ import { ActionConnectorTableItem } from '../../../../triggers_actions_ui/public
import { SectionWrapper } from '../wrappers';
import { Connectors } from './connectors';
import { ClosureOptions } from './closure_options';
import {
getConnectorById,
getNoneConnector,
normalizeActionConnector,
normalizeCaseConnector,
} from './utils';
import { getNoneConnector, normalizeActionConnector, normalizeCaseConnector } from './utils';
import * as i18n from './translations';
import { Owner } from '../../types';
import { OwnerProvider } from '../owner_context';
import { getConnectorById } from '../utils';
const FormWrapper = styled.div`
${({ theme }) => css`

View file

@ -10,10 +10,10 @@ import {
CaseField,
ActionType,
ThirdPartyField,
ActionConnector,
CaseConnector,
CaseConnectorMapping,
} from '../../containers/configure/types';
import { CaseActionConnector } from '../types';
export const setActionTypeToMapping = (
caseField: CaseField,
@ -54,13 +54,8 @@ export const getNoneConnector = (): CaseConnector => ({
fields: null,
});
export const getConnectorById = (
id: string,
connectors: ActionConnector[]
): ActionConnector | null => connectors.find((c) => c.id === id) ?? null;
export const normalizeActionConnector = (
actionConnector: ActionConnector,
actionConnector: CaseActionConnector,
fields: CaseConnector['fields'] = null
): CaseConnector => {
const caseConnectorFieldsType = {
@ -75,6 +70,6 @@ export const normalizeActionConnector = (
};
export const normalizeCaseConnector = (
connectors: ActionConnector[],
connectors: CaseActionConnector[],
caseConnector: CaseConnector
): ActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null;
): CaseActionConnector | null => connectors.find((c) => c.id === caseConnector.id) ?? null;

View file

@ -8,6 +8,7 @@
import React, { useCallback } from 'react';
import { isEmpty } from 'lodash/fp';
import { EuiFormRow } from '@elastic/eui';
import styled from 'styled-components';
import { FieldHook, getFieldValidityAndErrorMessage } from '../../common/shared_imports';
import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown';
@ -24,6 +25,13 @@ interface ConnectorSelectorProps {
handleChange?: (newValue: string) => void;
hideConnectorServiceNowSir?: boolean;
}
const EuiFormRowWrapper = styled(EuiFormRow)`
.euiFormErrorText {
display: none;
}
`;
export const ConnectorSelector = ({
connectors,
dataTestSubj,
@ -47,7 +55,7 @@ export const ConnectorSelector = ({
);
return isEdit ? (
<EuiFormRow
<EuiFormRowWrapper
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
error={errorMessage}
@ -65,6 +73,6 @@ export const ConnectorSelector = ({
onChange={onChange}
selectedConnector={isEmpty(field.value) ? 'none' : field.value}
/>
</EuiFormRow>
</EuiFormRowWrapper>
) : null;
};

View file

@ -8,7 +8,8 @@
import React, { memo, Suspense } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { CaseActionConnector, ConnectorFieldsProps } from './types';
import { CaseActionConnector } from '../types';
import { ConnectorFieldsProps } from './types';
import { getCaseConnectors } from '.';
import { ConnectorTypeFields } from '../../../common';

View file

@ -8,6 +8,7 @@
import { CaseConnectorsRegistry } from './types';
import { createCaseConnectorsRegistry } from './connectors_registry';
import { getCaseConnector as getJiraCaseConnector } from './jira';
import { getCaseConnector as getSwimlaneCaseConnector } from './swimlane';
import { getCaseConnector as getResilientCaseConnector } from './resilient';
import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow';
import {
@ -15,6 +16,7 @@ import {
ServiceNowITSMFieldsType,
ServiceNowSIRFieldsType,
ResilientFieldsType,
SwimlaneFieldsType,
} from '../../../common';
export { getActionType as getCaseConnectorUi } from './case';
@ -40,6 +42,7 @@ class CaseConnectors {
getServiceNowITSMCaseConnector()
);
this.caseConnectorsRegistry.register<ServiceNowSIRFieldsType>(getServiceNowSIRCaseConnector());
this.caseConnectorsRegistry.register<SwimlaneFieldsType>(getSwimlaneCaseConnector());
}
registry(): CaseConnectorsRegistry {

View file

@ -8,13 +8,13 @@
import { lazy } from 'react';
import { CaseConnector } from '../types';
import { JiraFieldsType } from '../../../../common';
import { ConnectorTypes, JiraFieldsType } from '../../../../common';
import * as i18n from './translations';
export * from './types';
export const getCaseConnector = (): CaseConnector<JiraFieldsType> => ({
id: '.jira',
id: ConnectorTypes.jira,
fieldsComponent: lazy(() => import('./case_fields')),
});
export const fieldLabels = {

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { SwimlaneConnectorType } from '../../../common';
export const connector = {
id: '123',
name: 'My connector',
@ -13,6 +15,22 @@ export const connector = {
isPreconfigured: false,
};
export const swimlaneConnector = {
id: '123',
name: 'My connector',
actionTypeId: '.swimlane',
config: {
connectorType: SwimlaneConnectorType.Cases,
mappings: {
caseIdConfig: {},
caseNameConfig: {},
descriptionConfig: {},
commentsConfig: {},
},
},
isPreconfigured: false,
};
export const issues = [
{ id: 'personId', title: 'Person Task', key: 'personKey' },
{ id: 'womanId', title: 'Woman Task', key: 'womanKey' },

View file

@ -8,13 +8,13 @@
import { lazy } from 'react';
import { CaseConnector } from '../types';
import { ResilientFieldsType } from '../../../../common';
import { ConnectorTypes, ResilientFieldsType } from '../../../../common';
import * as i18n from './translations';
export * from './types';
export const getCaseConnector = (): CaseConnector<ResilientFieldsType> => ({
id: '.resilient',
id: ConnectorTypes.resilient,
fieldsComponent: lazy(() => import('./case_fields')),
});

View file

@ -8,16 +8,20 @@
import { lazy } from 'react';
import { CaseConnector } from '../types';
import { ServiceNowITSMFieldsType, ServiceNowSIRFieldsType } from '../../../../common';
import {
ConnectorTypes,
ServiceNowITSMFieldsType,
ServiceNowSIRFieldsType,
} from '../../../../common';
import * as i18n from './translations';
export const getServiceNowITSMCaseConnector = (): CaseConnector<ServiceNowITSMFieldsType> => ({
id: '.servicenow',
id: ConnectorTypes.serviceNowITSM,
fieldsComponent: lazy(() => import('./servicenow_itsm_case_fields')),
});
export const getServiceNowSIRCaseConnector = (): CaseConnector<ServiceNowSIRFieldsType> => ({
id: '.servicenow-sir',
id: ConnectorTypes.serviceNowSIR,
fieldsComponent: lazy(() => import('./servicenow_sir_case_fields')),
});

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { SwimlaneConnectorType } from '../../../../common';
import Fields from './case_fields';
import * as i18n from './translations';
import { swimlaneConnector as connector } from '../mock';
const fields = {
caseId: '123',
};
const onChange = jest.fn();
describe('Swimlane Cases Fields', () => {
test('it does not shows the mapping error callout', () => {
render(<Fields connector={connector} fields={fields} onChange={onChange} />);
expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeFalsy();
});
test('it shows the mapping error callout when mapping is invalid', () => {
const invalidConnector = {
...connector,
config: {
...connector.config,
mappings: {},
},
};
render(<Fields connector={invalidConnector} fields={fields} onChange={onChange} />);
expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeTruthy();
});
test('it shows the mapping error callout when the connector is of type alerts', () => {
const invalidConnector = {
...connector,
config: {
...connector.config,
connectorType: SwimlaneConnectorType.Alerts,
},
};
render(<Fields connector={invalidConnector} fields={fields} onChange={onChange} />);
expect(screen.queryByText(i18n.EMPTY_MAPPING_WARNING_TITLE)).toBeTruthy();
});
});

View file

@ -0,0 +1,48 @@
/*
* 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, { useMemo } from 'react';
import { EuiCallOut } from '@elastic/eui';
import * as i18n from './translations';
import { ConnectorTypes, SwimlaneFieldsType } from '../../../../common';
import { ConnectorFieldsProps } from '../types';
import { ConnectorCard } from '../card';
import { connectorValidator } from './validator';
const SwimlaneComponent: React.FunctionComponent<ConnectorFieldsProps<SwimlaneFieldsType>> = ({
connector,
isEdit = true,
}) => {
const showMappingWarning = useMemo(() => connectorValidator(connector) != null, [connector]);
return (
<>
{!isEdit && (
<ConnectorCard
connectorType={ConnectorTypes.swimlane}
isLoading={false}
listItems={[]}
title={connector.name}
/>
)}
{showMappingWarning && (
<EuiCallOut
title={i18n.EMPTY_MAPPING_WARNING_TITLE}
color="danger"
iconType="alert"
data-test-subj="mapping-warning-callout"
>
{i18n.EMPTY_MAPPING_WARNING_DESC}
</EuiCallOut>
)}
</>
);
};
// eslint-disable-next-line import/no-default-export
export { SwimlaneComponent as default };

View file

@ -0,0 +1,25 @@
/*
* 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 { CaseConnector } from '../types';
import { ConnectorTypes, SwimlaneFieldsType } from '../../../../common';
import * as i18n from './translations';
export const getCaseConnector = (): CaseConnector<SwimlaneFieldsType> => {
return {
id: ConnectorTypes.swimlane,
fieldsComponent: lazy(() => import('./case_fields')),
};
};
export const fieldLabels = {
caseId: i18n.CASE_ID_LABEL,
caseName: i18n.CASE_NAME_LABEL,
severity: i18n.SEVERITY_LABEL,
};

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 { i18n } from '@kbn/i18n';
export const ALERT_SOURCE_LABEL = i18n.translate(
'xpack.cases.connectors.swimlane.alertSourceLabel',
{
defaultMessage: 'Alert Source',
}
);
export const CASE_ID_LABEL = i18n.translate('xpack.cases.connectors.swimlane.caseIdLabel', {
defaultMessage: 'Case Id',
});
export const CASE_NAME_LABEL = i18n.translate('xpack.cases.connectors.swimlane.caseNameLabel', {
defaultMessage: 'Case Name',
});
export const SEVERITY_LABEL = i18n.translate('xpack.cases.connectors.swimlane.severityLabel', {
defaultMessage: 'Severity',
});
export const EMPTY_MAPPING_WARNING_TITLE = i18n.translate(
'xpack.cases.connectors.swimlane.emptyMappingWarningTitle',
{
defaultMessage: 'This connector has missing field mappings',
}
);
export const EMPTY_MAPPING_WARNING_DESC = i18n.translate(
'xpack.cases.connectors.swimlane.emptyMappingWarningDesc',
{
defaultMessage:
'This connector cannot be selected because it is missing the required case field mappings. You can edit this connector to add required field mappings or select a connector of type Cases.',
}
);

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 { SwimlaneConnectorType } from '../../../../common';
import { swimlaneConnector as connector } from '../mock';
import { isAnyRequiredFieldNotSet, connectorValidator } from './validator';
describe('Swimlane validator', () => {
describe('isAnyRequiredFieldNotSet', () => {
test('it returns true if a required field is not set', () => {
expect(isAnyRequiredFieldNotSet({ notRequired: 'test' })).toBeTruthy();
});
test('it returns false if all required fields are set', () => {
expect(isAnyRequiredFieldNotSet(connector.config.mappings)).toBeFalsy();
});
});
describe('connectorValidator', () => {
test('it returns an error message if the mapping is not correct', () => {
const invalidConnector = {
...connector,
config: {
...connector.config,
mappings: {},
},
};
expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' });
});
test('it returns an error message if the connector is of type alerts', () => {
const invalidConnector = {
...connector,
config: {
...connector.config,
connectorType: SwimlaneConnectorType.Alerts,
},
};
expect(connectorValidator(invalidConnector)).toEqual({ message: 'Invalid connector' });
});
test.each([SwimlaneConnectorType.Cases, SwimlaneConnectorType.All])(
'it does not return an error message if the connector is of type %s',
(connectorType) => {
const invalidConnector = {
...connector,
config: {
...connector.config,
connectorType,
},
};
expect(connectorValidator(invalidConnector)).toBe(undefined);
}
);
});
});

View file

@ -0,0 +1,39 @@
/*
* 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 { SwimlaneConnectorType } from '../../../../common';
import { ValidationConfig } from '../../../common/shared_imports';
import { CaseActionConnector } from '../../types';
const casesRequiredFields = [
'caseIdConfig',
'caseNameConfig',
'descriptionConfig',
'commentsConfig',
];
export const isAnyRequiredFieldNotSet = (mapping: Record<string, unknown> | undefined) =>
casesRequiredFields.some((field) => mapping?.[field] == null);
/**
* The user can use either a connector of type cases or all.
* If the connector is of type all we should check if all
* required field have been configured.
*/
export const connectorValidator = (
connector: CaseActionConnector
): ReturnType<ValidationConfig['validator']> => {
const {
config: { mappings, connectorType },
} = connector;
if (connectorType === SwimlaneConnectorType.Alerts || isAnyRequiredFieldNotSet(mappings)) {
return {
message: 'Invalid connector',
};
}
};

View file

@ -11,12 +11,11 @@ import React from 'react';
import {
ActionType as ThirdPartySupportedActions,
CaseField,
ActionConnector,
ConnectorTypeFields,
} from '../../../common';
import { CaseActionConnector } from '../types';
export { ThirdPartyField as AllThirdPartyFields } from '../../../common';
export type CaseActionConnector = ActionConnector;
export interface ThirdPartyField {
label: string;

View file

@ -18,6 +18,9 @@ import { useGetSeverity } from '../connectors/resilient/use_get_severity';
import { useGetChoices } from '../connectors/servicenow/use_get_choices';
import { incidentTypes, severity, choices } from '../connectors/mock';
import { schema, FormProps } from './schema';
import { TestProviders } from '../../common/mock';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
jest.mock('../../common/lib/kibana', () => ({
useKibana: () => ({
@ -39,10 +42,12 @@ jest.mock('../../common/lib/kibana', () => ({
jest.mock('../connectors/resilient/use_get_incident_types');
jest.mock('../connectors/resilient/use_get_severity');
jest.mock('../connectors/servicenow/use_get_choices');
jest.mock('../../containers/configure/use_configure');
const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock;
const useGetSeverityMock = useGetSeverity as jest.Mock;
const useGetChoicesMock = useGetChoices as jest.Mock;
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
const useGetIncidentTypesResponse = {
isLoading: false,
@ -87,35 +92,30 @@ describe('Connector', () => {
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
useGetChoicesMock.mockReturnValue(useGetChoicesResponse);
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
});
it('it renders', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Connector {...defaultProps} />
</MockHookWrapperComponent>
<TestProviders>
<MockHookWrapperComponent>
<Connector {...defaultProps} />
</MockHookWrapperComponent>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeTruthy();
await waitFor(() => {
expect(wrapper.find(`button[data-test-subj="dropdown-connectors"]`).first().text()).toBe(
'My Connector'
);
});
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-fields-sn-itsm"]`).exists()).toBeTruthy();
});
// Selected connector is set to none so no fields should be displayed
expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeFalsy();
});
it('it is disabled and loading when isLoadingConnectors=true', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Connector {...{ ...defaultProps, isLoadingConnectors: true }} />
</MockHookWrapperComponent>
<TestProviders>
<MockHookWrapperComponent>
<Connector {...{ ...defaultProps, isLoadingConnectors: true }} />
</MockHookWrapperComponent>
</TestProviders>
);
expect(
@ -129,9 +129,11 @@ describe('Connector', () => {
it('it is disabled and loading when isLoading=true', async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Connector {...{ ...defaultProps, isLoading: true }} />
</MockHookWrapperComponent>
<TestProviders>
<MockHookWrapperComponent>
<Connector {...{ ...defaultProps, isLoading: true }} />
</MockHookWrapperComponent>
</TestProviders>
);
expect(
@ -144,9 +146,11 @@ describe('Connector', () => {
it(`it should change connector`, async () => {
const wrapper = mount(
<MockHookWrapperComponent>
<Connector {...defaultProps} />
</MockHookWrapperComponent>
<TestProviders>
<MockHookWrapperComponent>
<Connector {...defaultProps} />
</MockHookWrapperComponent>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy();

View file

@ -5,15 +5,22 @@
* 2.0.
*/
import React, { memo, useCallback } from 'react';
import React, { memo, useCallback, useMemo, useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { ActionConnector, ConnectorTypes } from '../../../common';
import { UseField, useFormData, FieldHook, useFormContext } from '../../common/shared_imports';
import { ConnectorTypes, ActionConnector } from '../../../common';
import {
UseField,
useFormData,
FieldHook,
useFormContext,
FieldConfig,
} from '../../common/shared_imports';
import { ConnectorSelector } from '../connector_selector/form';
import { ConnectorFieldsForm } from '../connectors/fields_form';
import { getConnectorById } from '../configure_cases/utils';
import { FormProps } from './schema';
import { FormProps, schema } from './schema';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { getConnectorById, getConnectorsFormValidators } from '../utils';
interface Props {
connectors: ActionConnector[];
@ -26,6 +33,7 @@ interface ConnectorsFieldProps {
connectors: ActionConnector[];
field: FieldHook<FormProps['fields']>;
isEdit: boolean;
setErrors: (errors: boolean) => void;
hideConnectorServiceNowSir?: boolean;
}
@ -33,11 +41,13 @@ const ConnectorFields = ({
connectors,
isEdit,
field,
setErrors,
hideConnectorServiceNowSir = false,
}: ConnectorsFieldProps) => {
const [{ connectorId }] = useFormData({ watch: ['connectorId'] });
const { setValue } = field;
let connector = getConnectorById(connectorId, connectors) ?? null;
if (
connector &&
hideConnectorServiceNowSir &&
@ -61,18 +71,49 @@ const ConnectorComponent: React.FC<Props> = ({
isLoading,
isLoadingConnectors,
}) => {
const { getFields } = useFormContext();
const { getFields, setFieldValue } = useFormContext();
const { connector: configurationConnector } = useCaseConfigure();
const handleConnectorChange = useCallback(() => {
const { fields } = getFields();
fields.setValue(null);
}, [getFields]);
const defaultConnectorId = useMemo(() => {
if (
hideConnectorServiceNowSir &&
configurationConnector.type === ConnectorTypes.serviceNowSIR
) {
return 'none';
}
return connectors.some((connector) => connector.id === configurationConnector.id)
? configurationConnector.id
: 'none';
}, [
configurationConnector.id,
configurationConnector.type,
connectors,
hideConnectorServiceNowSir,
]);
useEffect(() => setFieldValue('connectorId', defaultConnectorId), [
defaultConnectorId,
setFieldValue,
]);
const connectorIdConfig = getConnectorsFormValidators({
config: schema.connectorId as FieldConfig,
connectors,
});
return (
<EuiFlexGroup>
<EuiFlexItem>
<UseField
path="connectorId"
config={connectorIdConfig}
component={ConnectorSelector}
defaultValue={defaultConnectorId}
componentProps={{
connectors,
handleChange: handleConnectorChange,

View file

@ -17,11 +17,16 @@ import { schema, FormProps } from './schema';
import { CreateCaseForm } from './form';
import { OwnerProvider } from '../owner_context';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
jest.mock('../../containers/use_get_tags');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/configure/use_configure');
const useGetTagsMock = useGetTags as jest.Mock;
const useConnectorsMock = useConnectors as jest.Mock;
const useCaseConfigureMock = useCaseConfigure as jest.Mock;
const initialCaseValue: FormProps = {
description: '',
@ -54,6 +59,7 @@ describe('CreateCaseForm', () => {
jest.resetAllMocks();
useGetTagsMock.mockReturnValue({ tags: ['test'] });
useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock });
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
});
it('it renders with steps', async () => {

View file

@ -5,23 +5,19 @@
* 2.0.
*/
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { schema, FormProps } from './schema';
import { Form, useForm } from '../../common/shared_imports';
import {
getConnectorById,
getNoneConnector,
normalizeActionConnector,
} from '../configure_cases/utils';
import { getNoneConnector, normalizeActionConnector } from '../configure_cases/utils';
import { usePostCase } from '../../containers/use_post_case';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
import { useConnectors } from '../../containers/configure/use_connectors';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { Case } from '../../containers/types';
import { CaseType, ConnectorTypes } from '../../../common';
import { CaseType } from '../../../common';
import { UsePostComment, usePostComment } from '../../containers/use_post_comment';
import { useOwnerContext } from '../owner_context/use_owner_context';
import { getConnectorById } from '../utils';
const initialCaseValue: FormProps = {
description: '',
@ -49,28 +45,10 @@ export const FormContext: React.FC<Props> = ({
}) => {
const { connectors, loading: isLoadingConnectors } = useConnectors();
const owner = useOwnerContext();
const { connector: configurationConnector } = useCaseConfigure();
const { postCase } = usePostCase();
const { postComment } = usePostComment();
const { pushCaseToExternalService } = usePostPushToService();
const connectorId = useMemo(() => {
if (
hideConnectorServiceNowSir &&
configurationConnector.type === ConnectorTypes.serviceNowSIR
) {
return 'none';
}
return connectors.some((connector) => connector.id === configurationConnector.id)
? configurationConnector.id
: 'none';
}, [
configurationConnector.id,
configurationConnector.type,
connectors,
hideConnectorServiceNowSir,
]);
const submitCase = useCallback(
async (
{ connectorId: dataConnectorId, fields, syncAlerts = true, ...dataWithoutConnectorId },
@ -125,9 +103,6 @@ export const FormContext: React.FC<Props> = ({
schema,
onSubmit: submitCase,
});
const { setFieldValue } = form;
// Set the selected connector to the configuration connector
useEffect(() => setFieldValue('connectorId', connectorId), [connectorId, setFieldValue]);
const childrenWithExtraProp = useMemo(
() =>

View file

@ -49,7 +49,9 @@ export const schema: FormSchema<FormProps> = {
label: i18n.CONNECTORS,
defaultValue: 'none',
},
fields: {},
fields: {
defaultValue: null,
},
syncAlerts: {
helpText: i18n.SYNC_ALERTS_HELP,
type: FIELD_TYPES.TOGGLE,

View file

@ -20,15 +20,15 @@ import {
import styled from 'styled-components';
import { noop } from 'lodash/fp';
import { Form, UseField, useForm } from '../../common/shared_imports';
import { FieldConfig, Form, UseField, useForm } from '../../common/shared_imports';
import { ActionConnector, ConnectorTypeFields } from '../../../common';
import { ConnectorSelector } from '../connector_selector/form';
import { ConnectorFieldsForm } from '../connectors/fields_form';
import { getConnectorById } from '../configure_cases/utils';
import { CaseUserActions } from '../../containers/types';
import { schema } from './schema';
import { getConnectorFieldsFromUserActions } from './helpers';
import * as i18n from './translations';
import { getConnectorById, getConnectorsFormValidators } from '../utils';
export interface EditConnectorProps {
caseFields: ConnectorTypeFields['fields'];
@ -205,6 +205,11 @@ export const EditConnector = React.memo(
});
}, [dispatch]);
const connectorIdConfig = getConnectorsFormValidators({
config: schema.connectorId as FieldConfig,
connectors,
});
/**
* if this evaluates to true it means that the connector was likely deleted because the case connector was set to something
* other than none but we don't find it in the list of connectors returned from the actions plugin
@ -243,6 +248,7 @@ export const EditConnector = React.memo(
<EuiFlexItem>
<UseField
path="connectorId"
config={connectorIdConfig}
component={ConnectorSelector}
componentProps={{
connectors,

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 { ActionConnector } from '../../common';
export type CaseActionConnector = ActionConnector;

View file

@ -0,0 +1,43 @@
/*
* 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 { ConnectorTypes } from '../../common';
import { FieldConfig, ValidationConfig } from '../common/shared_imports';
import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator';
import { CaseActionConnector } from './types';
export const getConnectorById = (
id: string,
connectors: CaseActionConnector[]
): CaseActionConnector | null => connectors.find((c) => c.id === id) ?? null;
const validators: Record<
string,
(connector: CaseActionConnector) => ReturnType<ValidationConfig['validator']>
> = {
[ConnectorTypes.swimlane]: swimlaneConnectorValidator,
};
export const getConnectorsFormValidators = ({
connectors = [],
config = {},
}: {
connectors: CaseActionConnector[];
config: FieldConfig;
}): FieldConfig => ({
...config,
validations: [
{
validator: ({ value: connectorId }) => {
const connector = getConnectorById(connectorId as string, connectors);
if (connector != null) {
return validators[connector.actionTypeId]?.(connector);
}
},
},
],
});

View file

@ -11,6 +11,7 @@ import { useToasts } from '../common/lib/kibana';
import { getActionLicense } from './api';
import * as i18n from './translations';
import { ActionLicense } from './types';
import { ConnectorTypes } from '../../common';
export interface ActionLicenseState {
actionLicense: ActionLicense | null;
@ -24,7 +25,7 @@ export const initialData: ActionLicenseState = {
isError: false,
};
const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira';
const MINIMUM_LICENSE_REQUIRED_CONNECTOR = ConnectorTypes.jira;
export const useGetActionLicense = (): ActionLicenseState => {
const [actionLicenseState, setActionLicensesState] = useState<ActionLicenseState>(initialData);

View file

@ -173,7 +173,6 @@ export const get = async (
let theCase: SavedObject<ESCaseAttributes>;
let subCaseIds: string[] = [];
if (ENABLE_CASE_CONNECTOR) {
const [caseInfo, subCasesForCaseId] = await Promise.all([
caseService.getCase({

View file

@ -252,6 +252,7 @@ export const prepareFieldsForTransformation = ({
mappings.reduce(
(acc: PipedField[], mapping) =>
mapping != null &&
mapping.target != null &&
mapping.target !== 'not_mapped' &&
mapping.action_type !== 'nothing' &&
mapping.source !== 'comments'

View file

@ -60,7 +60,7 @@ describe('case connector', () => {
connector: {
id: 'jira',
name: 'Jira',
type: '.jira',
type: ConnectorTypes.jira,
fields: {
issueType: '10006',
priority: 'High',
@ -99,7 +99,7 @@ describe('case connector', () => {
connector: {
id: 'jira',
name: 'Jira',
type: '.jira',
type: ConnectorTypes.jira,
fields: {
issueType: '10006',
priority: 'High',
@ -293,7 +293,7 @@ describe('case connector', () => {
connector: {
id: 'jira',
name: 'Jira',
type: '.jira',
type: ConnectorTypes.jira,
fields: {
priority: 'High',
parent: null,
@ -438,7 +438,7 @@ describe('case connector', () => {
connector: {
id: 'jira',
name: 'Jira',
type: '.jira',
type: ConnectorTypes.jira,
fields: {
issueType: '10006',
priority: 'High',
@ -640,7 +640,7 @@ describe('case connector', () => {
connector: {
id: 'jira',
name: 'Jira',
type: '.jira',
type: ConnectorTypes.jira,
fields: {
priority: 'High',
parent: null,
@ -974,7 +974,7 @@ describe('case connector', () => {
connector: {
id: 'jira',
name: 'Jira',
type: '.jira',
type: ConnectorTypes.jira,
fields: {
issueType: '10006',
priority: 'High',
@ -1003,7 +1003,7 @@ describe('case connector', () => {
connector: {
id: 'jira',
name: 'Jira',
type: '.jira',
type: ConnectorTypes.jira,
fields: {
issueType: '10006',
priority: 'High',

View file

@ -6,7 +6,7 @@
*/
import { schema } from '@kbn/config-schema';
import { CommentType } from '../../../common';
import { CommentType, ConnectorTypes } from '../../../common';
import { validateConnector } from './validators';
// Reserved for future implementation
@ -77,23 +77,29 @@ const ServiceNowSIRFieldsSchema = schema.object({
subcategory: schema.nullable(schema.string()),
});
const SwimlaneFieldsSchema = schema.object({
caseId: schema.nullable(schema.string()),
});
const NoneFieldsSchema = schema.nullable(schema.object({}));
const ReducedConnectorFieldsSchema: { [x: string]: any } = {
'.jira': JiraFieldsSchema,
'.resilient': ResilientFieldsSchema,
'.servicenow-sir': ServiceNowSIRFieldsSchema,
[ConnectorTypes.jira]: JiraFieldsSchema,
[ConnectorTypes.resilient]: ResilientFieldsSchema,
[ConnectorTypes.serviceNowSIR]: ServiceNowSIRFieldsSchema,
[ConnectorTypes.swimlane]: SwimlaneFieldsSchema,
};
export const ConnectorProps = {
id: schema.string(),
name: schema.string(),
type: schema.oneOf([
schema.literal('.servicenow'),
schema.literal('.jira'),
schema.literal('.resilient'),
schema.literal('.servicenow-sir'),
schema.literal('.none'),
schema.literal(ConnectorTypes.jira),
schema.literal(ConnectorTypes.none),
schema.literal(ConnectorTypes.resilient),
schema.literal(ConnectorTypes.serviceNowITSM),
schema.literal(ConnectorTypes.serviceNowSIR),
schema.literal(ConnectorTypes.swimlane),
]),
// Chain of conditional schemes
fields: Object.keys(ReducedConnectorFieldsSchema).reduce(
@ -106,7 +112,7 @@ export const ConnectorProps = {
),
schema.conditional(
schema.siblingRef('type'),
'.servicenow',
ConnectorTypes.serviceNowITSM,
ServiceNowITSMFieldsSchema,
NoneFieldsSchema
)

View file

@ -6,9 +6,10 @@
*/
import { Connector } from './types';
import { ConnectorTypes } from '../../../common';
export const validateConnector = (connector: Connector) => {
if (connector.type === '.none' && connector.fields !== null) {
if (connector.type === ConnectorTypes.none && connector.fields !== null) {
return 'Fields must be set to null for connectors of type .none';
}
};

View file

@ -6,16 +6,18 @@
*/
import { ConnectorTypes } from '../../common';
import { ICasesConnector, CasesConnectorsMap } from './types';
import { getCaseConnector as getJiraCaseConnector } from './jira';
import { getCaseConnector as getResilientCaseConnector } from './resilient';
import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow';
import { ICasesConnector, CasesConnectorsMap } from './types';
import { getCaseConnector as getSwimlaneCaseConnector } from './swimlane';
const mapping: Record<ConnectorTypes, ICasesConnector | null> = {
[ConnectorTypes.jira]: getJiraCaseConnector(),
[ConnectorTypes.serviceNowITSM]: getServiceNowITSMCaseConnector(),
[ConnectorTypes.serviceNowSIR]: getServiceNowSIRCaseConnector(),
[ConnectorTypes.resilient]: getResilientCaseConnector(),
[ConnectorTypes.swimlane]: getSwimlaneCaseConnector(),
[ConnectorTypes.none]: null,
};

View file

@ -0,0 +1,21 @@
/*
* 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 { CaseResponse } from '../../../common';
import { format } from './format';
describe('Swimlane formatter', () => {
const theCase = {
id: 'case-id',
connector: { fields: null },
} as CaseResponse;
it('it formats correctly', async () => {
const res = await format(theCase, []);
expect(res).toEqual({ caseId: theCase.id });
});
});

View file

@ -0,0 +1,15 @@
/*
* 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 { ConnectorSwimlaneTypeFields } from '../../../common';
import { Format } from './types';
export const format: Format = (theCase) => {
const { caseId = theCase.id } =
(theCase.connector.fields as ConnectorSwimlaneTypeFields['fields']) ?? {};
return { caseId };
};

View file

@ -0,0 +1,15 @@
/*
* 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 { getMapping } from './mapping';
import { format } from './format';
import { SwimlaneCaseConnector } from './types';
export const getCaseConnector = (): SwimlaneCaseConnector => ({
getMapping,
format,
});

View file

@ -0,0 +1,28 @@
/*
* 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 { GetMapping } from './types';
export const getMapping: GetMapping = () => {
return [
{
source: 'title',
target: 'caseName',
action_type: 'overwrite',
},
{
source: 'description',
target: 'description',
action_type: 'overwrite',
},
{
source: 'comments',
target: 'comments',
action_type: 'append',
},
];
};

View file

@ -0,0 +1,13 @@
/*
* 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 { SwimlaneFieldsType } from '../../../common/api';
import { ICasesConnector } from '../types';
export type SwimlaneCaseConnector = ICasesConnector<SwimlaneFieldsType>;
export type Format = ICasesConnector<SwimlaneFieldsType>['format'];
export type GetMapping = ICasesConnector<SwimlaneFieldsType>['getMapping'];

View file

@ -240,6 +240,7 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [
'.email',
'.slack',
'.pagerduty',
'.swimlane',
'.webhook',
'.servicenow',
'.jira',

View file

@ -31,6 +31,9 @@
"__index": {
"type": "long"
},
"__swimlane": {
"type": "long"
},
"__pagerduty": {
"type": "long"
},
@ -68,6 +71,9 @@
"__index": {
"type": "long"
},
"__swimlane": {
"type": "long"
},
"__pagerduty": {
"type": "long"
},

View file

@ -10,6 +10,7 @@ import { getSlackActionType } from './slack';
import { getEmailActionType } from './email';
import { getIndexActionType } from './es_index';
import { getPagerDutyActionType } from './pagerduty';
import { getSwimlaneActionType } from './swimlane';
import { getWebhookActionType } from './webhook';
import { TypeRegistry } from '../../type_registry';
import { ActionTypeModel } from '../../../types';
@ -28,6 +29,7 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getEmailActionType());
actionTypeRegistry.register(getIndexActionType());
actionTypeRegistry.register(getPagerDutyActionType());
actionTypeRegistry.register(getSwimlaneActionType());
actionTypeRegistry.register(getWebhookActionType());
actionTypeRegistry.register(getServiceNowITSMActionType());
actionTypeRegistry.register(getServiceNowSIRActionType());

View file

@ -12,7 +12,7 @@ import { JiraActionConnector } from './types';
jest.mock('../../../../common/lib/kibana');
describe('JiraActionConnectorFields renders', () => {
test('alerting Jira connector fields is rendered', () => {
test('alerting Jira connector fields are rendered', () => {
const actionConnector = {
secrets: {
email: 'email',

View file

@ -63,6 +63,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
actionConnector,
issueType: incident.issueType ?? '',
});
const editSubActionProperty = useCallback(
(key: string, value: any) => {
if (key === 'issueType') {
@ -75,9 +76,11 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
index
);
}
if (key === 'comments') {
return editAction('subActionParams', { incident, comments: value }, index);
}
return editAction(
'subActionParams',
{
@ -124,6 +127,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
text: type.name ?? '',
}));
}, [editSubActionProperty, incident, issueTypes]);
const prioritiesSelectOptions: EuiSelectOption[] = useMemo(() => {
if (incident.issueType != null && fields != null) {
const priorities = fields.priority != null ? fields.priority.allowedValues : [];
@ -141,6 +145,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
}
return [];
}, [editSubActionProperty, fields, incident.issueType, incident.priority]);
useEffect(() => {
if (!hasPriority && incident.priority != null) {
editSubActionProperty('priority', null);
@ -167,6 +172,7 @@ const JiraParamsFields: React.FunctionComponent<ActionParamsProps<JiraActionPara
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionConnector]);
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);

View file

@ -12,7 +12,7 @@ import { ResilientActionConnector } from './types';
jest.mock('../../../../common/lib/kibana');
describe('ResilientActionConnectorFields renders', () => {
test('alerting Resilient connector fields is rendered', () => {
test('alerting Resilient connector fields are rendered', () => {
const actionConnector = {
secrets: {
apiKeyId: 'key',

View file

@ -147,6 +147,7 @@ const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<Resilient
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionConnector]);
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);

View file

@ -12,7 +12,7 @@ import { ServiceNowActionConnector } from './types';
jest.mock('../../../../common/lib/kibana');
describe('ServiceNowActionConnectorFields renders', () => {
test('alerting servicenow connector fields is rendered', () => {
test('alerting servicenow connector fields are rendered', () => {
const actionConnector = {
secrets: {
username: 'user',

View file

@ -0,0 +1,145 @@
/*
* 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 { getApplication } from './api';
const getApplicationResponse = {
fields: [],
};
describe('Swimlane API', () => {
let fetchMock: jest.SpyInstance<Promise<unknown>>;
beforeAll(() => jest.spyOn(window, 'fetch'));
beforeEach(() => {
jest.resetAllMocks();
fetchMock = jest.spyOn(window, 'fetch');
});
describe('getApplication', () => {
it('should call getApplication API correctly', async () => {
const abortCtrl = new AbortController();
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => getApplicationResponse,
});
const res = await getApplication({
signal: abortCtrl.signal,
apiToken: '',
appId: '',
url: '',
});
expect(res).toEqual(getApplicationResponse);
});
it('returns an error when the response fails', async () => {
const abortCtrl = new AbortController();
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => getApplicationResponse,
});
try {
await getApplication({
signal: abortCtrl.signal,
apiToken: '',
appId: '',
url: '',
});
} catch (e) {
expect(e.message).toContain('Received status:');
}
});
it('returns an error when parsing the json fails', async () => {
const abortCtrl = new AbortController();
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error('bad');
},
});
try {
await getApplication({
signal: abortCtrl.signal,
apiToken: '',
appId: '',
url: '',
});
} catch (e) {
expect(e.message).toContain('bad');
}
});
it('it removes unsafe fields', async () => {
const abortCtrl = new AbortController();
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
fields: [
{
id: '__proto__',
name: 'Alert Id',
key: 'alert-id',
fieldType: 'text',
},
{
id: 'a6ide',
name: '__proto__',
key: 'alert-id',
fieldType: 'text',
},
{
id: 'a6ide',
name: 'Alert Id',
key: '__proto__',
fieldType: 'text',
},
{
id: 'a6ide',
name: 'Alert Id',
key: 'alert-id',
fieldType: '__proto__',
},
{
id: 'safe-id',
name: 'Safe',
key: 'safe-key',
fieldType: 'safe-text',
},
],
}),
});
const res = await getApplication({
signal: abortCtrl.signal,
apiToken: '',
appId: '',
url: '',
});
expect(res).toEqual({
fields: [
{
id: 'safe-id',
name: 'Safe',
key: 'safe-key',
fieldType: 'safe-text',
},
],
});
});
});
});

View file

@ -0,0 +1,65 @@
/*
* 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 { SwimlaneFieldMappingConfig } from './types';
const removeUnsafeFields = (fields: SwimlaneFieldMappingConfig[]): SwimlaneFieldMappingConfig[] =>
fields.filter(
(filter) =>
filter.id !== '__proto__' &&
filter.key !== '__proto__' &&
filter.name !== '__proto__' &&
filter.fieldType !== '__proto__'
);
export async function getApplication({
signal,
url,
appId,
apiToken,
}: {
signal: AbortSignal;
url: string;
appId: string;
apiToken: string;
}): Promise<Record<string, any>> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Private-Token': `${apiToken}`,
};
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
const apiUrl = urlWithoutTrailingSlash.endsWith('api')
? urlWithoutTrailingSlash
: urlWithoutTrailingSlash + '/api';
const applicationUrl = `${apiUrl}/app/{appId}`;
const getApplicationUrl = (id: string) => applicationUrl.replace('{appId}', id);
try {
const response = await fetch(getApplicationUrl(appId), {
method: 'GET',
headers,
signal,
});
/**
* Fetch do not throw when there is an HTTP error (status >= 400).
* We need to do it manually.
*/
if (!response.ok) {
throw new Error(
`Received status: ${response.status} when attempting to get application with id: ${appId}`
);
}
const data = await response.json();
return { ...data, fields: removeUnsafeFields(data?.fields ?? []) };
} catch (error) {
throw new Error(`Unable to get application with id ${appId}. Error: ${error.message}`);
}
}

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 { SwimlaneConnectorType, SwimlaneMappingConfig, MappingConfigurationKeys } from './types';
import * as i18n from './translations';
const casesRequiredFields: MappingConfigurationKeys[] = [
'caseNameConfig',
'descriptionConfig',
'commentsConfig',
'caseIdConfig',
];
const casesFields = [...casesRequiredFields];
const alertsRequiredFields: MappingConfigurationKeys[] = ['ruleNameConfig', 'alertIdConfig'];
const alertsFields = ['severityConfig', 'commentsConfig', ...alertsRequiredFields];
const translationMapping: Record<string, string> = {
caseIdConfig: i18n.SW_REQUIRED_CASE_ID,
alertIdConfig: i18n.SW_REQUIRED_ALERT_ID,
caseNameConfig: i18n.SW_REQUIRED_CASE_NAME,
descriptionConfig: i18n.SW_REQUIRED_DESCRIPTION,
commentsConfig: i18n.SW_REQUIRED_COMMENTS,
ruleNameConfig: i18n.SW_REQUIRED_RULE_NAME,
severityConfig: i18n.SW_REQUIRED_SEVERITY,
};
export const isValidFieldForConnector = (
connector: SwimlaneConnectorType,
field: MappingConfigurationKeys
): boolean => {
if (connector === SwimlaneConnectorType.All) {
return true;
}
return connector === SwimlaneConnectorType.Alerts
? alertsFields.includes(field)
: casesFields.includes(field);
};
export const validateMappingForConnector = (
connectorType: SwimlaneConnectorType,
mapping: SwimlaneMappingConfig
): Record<string, string> => {
if (connectorType === SwimlaneConnectorType.All || connectorType == null) {
return {};
}
const requiredFields =
connectorType === SwimlaneConnectorType.Alerts ? alertsRequiredFields : casesRequiredFields;
return requiredFields.reduce((errors, field) => {
if (mapping?.[field] == null) {
errors = { ...errors, [field]: translationMapping[field] };
}
return errors;
}, {} as Record<string, string>);
};

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { getActionType as getSwimlaneActionType } from './swimlane';

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
const Logo = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="none"
stroke="null"
vectorEffect="non-scaling-stroke"
>
<g fillRule="evenodd" stroke="null" clipRule="evenodd">
<path
fill="#19CCC0"
d="M21.536 9.338a1.053 1.053 0 00-1.29 0l-4.564 3.566a.988.988 0 000 1.566l4.725 3.691c.02.016.05.016.07 0l6.12-4.782a.054.054 0 000-.086l-5.061-3.955z"
vectorEffect="non-scaling-stroke"
/>
<path
fill="#00FFF4"
d="M15.684 31.61l10.728-8.382a.627.627 0 00.244-.494v-9.401l-11.787 9.21a.627.627 0 00-.244.494v8.08c0 .531.633.827 1.059.494z"
vectorEffect="non-scaling-stroke"
/>
<path
fill="#27AFA2"
d="M26.655 13.331L20.44 18.19l-1.703-1.331 7.917-3.527z"
vectorEffect="non-scaling-stroke"
/>
<path
fill="#028ACF"
d="M10.464 22.663c.377.294.914.294 1.291 0l4.563-3.566a.987.987 0 000-1.565l-4.724-3.692a.058.058 0 00-.071 0l-6.12 4.782a.054.054 0 000 .086l5.061 3.955z"
vectorEffect="non-scaling-stroke"
/>
<path
fill="#02AAFF"
d="M16.316.39L5.588 8.771a.628.628 0 00-.244.494v9.401l11.787-9.21a.628.628 0 00.244-.494V.883c0-.531-.633-.827-1.059-.494z"
vectorEffect="non-scaling-stroke"
/>
<path fill="#0578A5" d="M5.344 18.67l6.214-4.858 1.704 1.331-7.918 3.527z" />
</g>
</svg>
);
};
// eslint-disable-next-line import/no-default-export
export { Logo as default };

View file

@ -0,0 +1,61 @@
/*
* 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 applicationFields = [
{
id: 'a6ide',
name: 'Alert Id',
key: 'alert-id',
fieldType: 'text',
},
{
id: 'adnlas',
name: 'Severity',
key: 'severity',
fieldType: 'text',
},
{
id: 'adnfls',
name: 'Rule Name',
key: 'rule-name',
fieldType: 'text',
},
{
id: 'a6sst',
name: 'Case Id',
key: 'case-id-name',
fieldType: 'text',
},
{
id: 'a6fst',
name: 'Case Name',
key: 'case-name',
fieldType: 'text',
},
{
id: 'a6fdf',
name: 'Comments',
key: 'notes',
fieldType: 'comments',
},
{
id: 'a6fde',
name: 'Description',
key: 'description',
fieldType: 'text',
},
];
export const mappings = {
alertIdConfig: applicationFields[0],
severityConfig: applicationFields[1],
ruleNameConfig: applicationFields[2],
caseIdConfig: applicationFields[3],
caseNameConfig: applicationFields[4],
commentsConfig: applicationFields[5],
descriptionConfig: applicationFields[6],
};

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 { SwimlaneConnection } from './swimlane_connection';
export { SwimlaneFields } from './swimlane_fields';

View file

@ -0,0 +1,201 @@
/*
* 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 {
EuiButton,
EuiCallOut,
EuiFieldText,
EuiFormRow,
EuiLink,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import React, { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import * as i18n from '../translations';
import { useKibana } from '../../../../../common/lib/kibana';
import { useGetApplication } from '../use_get_application';
import { SwimlaneActionConnector, SwimlaneFieldMappingConfig } from '../types';
import { IErrorObject } from '../../../../../types';
interface Props {
action: SwimlaneActionConnector;
editActionConfig: (property: string, value: any) => void;
editActionSecrets: (property: string, value: any) => void;
errors: IErrorObject;
readOnly: boolean;
updateCurrentStep: (step: number) => void;
updateFields: (items: SwimlaneFieldMappingConfig[]) => void;
}
const SwimlaneConnectionComponent: React.FunctionComponent<Props> = ({
action,
editActionConfig,
editActionSecrets,
errors,
readOnly,
updateCurrentStep,
updateFields,
}) => {
const {
notifications: { toasts },
} = useKibana().services;
const { apiUrl, appId } = action.config;
const { apiToken } = action.secrets;
const { docLinks } = useKibana().services;
const { getApplication } = useGetApplication({
toastNotifications: toasts,
apiToken,
appId,
apiUrl,
});
const isValid = apiUrl && apiToken && appId;
const connectSwimlane = useCallback(async () => {
// fetch swimlane application configuration
const application = await getApplication();
if (application?.fields) {
const allFields = application.fields;
updateFields(allFields);
updateCurrentStep(2);
}
}, [getApplication, updateCurrentStep, updateFields]);
const onChangeConfig = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, key: 'apiUrl' | 'appId') => {
editActionConfig(key, e.target.value);
},
[editActionConfig]
);
const onBlurConfig = useCallback(
(key: 'apiUrl' | 'appId') => {
if (!action.config[key]) {
editActionConfig(key, '');
}
},
[action.config, editActionConfig]
);
const onChangeSecrets = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
editActionSecrets('apiToken', e.target.value);
},
[editActionSecrets]
);
const onBlurSecrets = useCallback(() => {
if (!apiToken) {
editActionSecrets('apiToken', '');
}
}, [apiToken, editActionSecrets]);
const isApiUrlInvalid = errors.apiUrl?.length > 0 && apiToken !== undefined;
const isAppIdInvalid = errors.appId?.length > 0 && apiToken !== undefined;
const isApiTokenInvalid = errors.apiToken?.length > 0 && apiToken !== undefined;
return (
<>
<EuiFormRow
id="apiUrl"
fullWidth
label={i18n.SW_API_URL_TEXT_FIELD_LABEL}
error={errors.apiUrl}
isInvalid={isApiUrlInvalid}
>
<EuiFieldText
fullWidth
name="apiUrl"
value={apiUrl ?? ''}
readOnly={readOnly}
isInvalid={isApiUrlInvalid}
data-test-subj="swimlaneApiUrlInput"
onChange={(e) => onChangeConfig(e, 'apiUrl')}
onBlur={() => onBlurConfig('apiUrl')}
/>
</EuiFormRow>
<EuiFormRow
id="appId"
fullWidth
label={i18n.SW_APP_ID_TEXT_FIELD_LABEL}
error={errors.appId}
isInvalid={isAppIdInvalid}
>
<EuiFieldText
fullWidth
name="appId"
value={appId ?? ''}
readOnly={readOnly}
isInvalid={isAppIdInvalid}
data-test-subj="swimlaneAppIdInput"
onChange={(e) => onChangeConfig(e, 'appId')}
onBlur={() => onBlurConfig('appId')}
/>
</EuiFormRow>
<EuiFormRow
id="apiToken"
fullWidth
helpText={
<EuiLink
href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/swimlane-action-type.html`}
target="_blank"
>
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.apiTokenNameHelpLabel"
defaultMessage="Provide a Swimlane API Token"
/>
</EuiLink>
}
error={errors.apiToken}
isInvalid={isApiTokenInvalid}
label={i18n.SW_API_TOKEN_TEXT_FIELD_LABEL}
>
<>
{!action.id ? (
<>
<EuiSpacer size="s" />
<EuiText size="s" data-test-subj="rememberValuesMessage">
{i18n.SW_REMEMBER_VALUE_LABEL}
</EuiText>
<EuiSpacer size="s" />
</>
) : (
<>
<EuiSpacer size="s" />
<EuiCallOut
size="s"
iconType="iInCircle"
data-test-subj="reenterValuesMessage"
title={i18n.SW_REENTER_VALUE_LABEL}
/>
<EuiSpacer size="m" />
</>
)}
<EuiFieldText
fullWidth
isInvalid={isApiTokenInvalid}
readOnly={readOnly}
value={apiToken ?? ''}
data-test-subj="swimlaneApiTokenInput"
onChange={onChangeSecrets}
onBlur={onBlurSecrets}
/>
</>
</EuiFormRow>
<EuiSpacer />
<EuiButton
disabled={!isValid}
onClick={connectSwimlane}
data-test-subj="swimlaneConfigureMapping"
>
{i18n.SW_RETRIEVE_CONFIGURATION_LABEL}
</EuiButton>
</>
);
};
export const SwimlaneConnection = React.memo(SwimlaneConnectionComponent);

View file

@ -0,0 +1,313 @@
/*
* 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, { useMemo, useCallback, useEffect, useRef } from 'react';
import {
EuiButton,
EuiFormRow,
EuiComboBox,
EuiComboBoxOptionOption,
EuiButtonGroup,
} from '@elastic/eui';
import * as i18n from '../translations';
import {
SwimlaneActionConnector,
SwimlaneConnectorType,
SwimlaneFieldMappingConfig,
SwimlaneMappingConfig,
} from '../types';
import { IErrorObject } from '../../../../../types';
import { isValidFieldForConnector } from '../helpers';
const SINGLE_SELECTION = { asPlainText: true };
const EMPTY_COMBO_BOX_ARRAY: Array<EuiComboBoxOptionOption<string>> | undefined = [];
const formatOption = (field: SwimlaneFieldMappingConfig) => ({
label: `${field.name} (${field.key})`,
value: field.id,
});
const createSelectedOption = (field: SwimlaneFieldMappingConfig | null | undefined) =>
field != null ? [formatOption(field)] : EMPTY_COMBO_BOX_ARRAY;
interface Props {
action: SwimlaneActionConnector;
editActionConfig: (property: string, value: any) => void;
updateCurrentStep: (step: number) => void;
fields: SwimlaneFieldMappingConfig[];
errors: IErrorObject;
}
const connectorTypeButtons = [
{ id: 'all', label: 'All' },
{ id: 'alerts', label: 'Alerts' },
{ id: 'cases', label: 'Cases' },
];
const SwimlaneFieldsComponent: React.FC<Props> = ({
action,
editActionConfig,
updateCurrentStep,
fields,
errors,
}) => {
const { mappings, connectorType = SwimlaneConnectorType.All } = action.config;
const prevConnectorType = useRef<SwimlaneConnectorType>(connectorType);
const hasChangedConnectorType = connectorType !== prevConnectorType.current;
const [fieldTypeMap, fieldIdMap] = useMemo(
() =>
fields.reduce(
([typeMap, idMap], field) => {
if (field != null) {
typeMap.set(field.fieldType, [
...(typeMap.get(field.fieldType) ?? []),
formatOption(field),
]);
idMap.set(field.id, field);
}
return [typeMap, idMap];
},
[
new Map<string, Array<EuiComboBoxOptionOption<string>>>(),
new Map<string, SwimlaneFieldMappingConfig>(),
]
),
[fields]
);
const textOptions = useMemo(() => fieldTypeMap.get('text') ?? [], [fieldTypeMap]);
const commentsOptions = useMemo(() => fieldTypeMap.get('comments') ?? [], [fieldTypeMap]);
const state = useMemo(
() => ({
alertIdConfig: createSelectedOption(mappings?.alertIdConfig),
severityConfig: createSelectedOption(mappings?.severityConfig),
ruleNameConfig: createSelectedOption(mappings?.ruleNameConfig),
caseIdConfig: createSelectedOption(mappings?.caseIdConfig),
caseNameConfig: createSelectedOption(mappings?.caseNameConfig),
commentsConfig: createSelectedOption(mappings?.commentsConfig),
descriptionConfig: createSelectedOption(mappings?.descriptionConfig),
}),
[mappings]
);
const mappingErrors: Record<string, string> = useMemo(
() => (Array.isArray(errors?.mappings) ? errors?.mappings[0] : {}),
[errors]
);
const resetConnection = useCallback(() => {
updateCurrentStep(1);
}, [updateCurrentStep]);
const editMappings = useCallback(
(key: keyof SwimlaneMappingConfig, e: Array<EuiComboBoxOptionOption<string>>) => {
if (e.length === 0) {
const newProps = {
...mappings,
[key]: null,
};
editActionConfig('mappings', newProps);
return;
}
const option = e[0];
const item = fieldIdMap.get(option.value ?? '');
if (!item) {
return;
}
const newProps = {
...mappings,
[key]: { id: item.id, name: item.name, key: item.key, fieldType: item.fieldType },
};
editActionConfig('mappings', newProps);
},
[editActionConfig, fieldIdMap, mappings]
);
/**
* Connector type needs to be updated on mount to All.
* Otherwise it is undefined and this will cause an error
* if the user saves the connector without any mapping
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => editActionConfig('connectorType', connectorType), []);
useEffect(() => {
if (connectorType !== prevConnectorType.current) {
prevConnectorType.current = connectorType;
}
}, [connectorType]);
return (
<>
<EuiFormRow id="connectorType" fullWidth label={i18n.SW_CONNECTOR_TYPE_LABEL}>
<EuiButtonGroup
name="connectorType"
legend={i18n.SW_CONNECTOR_TYPE_LABEL}
options={connectorTypeButtons}
idSelected={connectorType}
onChange={(type) => editActionConfig('connectorType', type)}
buttonSize="compressed"
/>
</EuiFormRow>
{isValidFieldForConnector(connectorType as SwimlaneConnectorType.All, 'alertIdConfig') && (
<>
<EuiFormRow
id="alertIdConfig"
fullWidth
label={i18n.SW_ALERT_ID_FIELD_LABEL}
error={mappingErrors?.alertIdConfig}
isInvalid={mappingErrors?.alertIdConfig != null && !hasChangedConnectorType}
>
<EuiComboBox
fullWidth
selectedOptions={state.alertIdConfig}
options={textOptions}
singleSelection={SINGLE_SELECTION}
data-test-subj="swimlaneAlertIdInput"
onChange={(e) => editMappings('alertIdConfig', e)}
isInvalid={mappingErrors?.alertIdConfig != null && !hasChangedConnectorType}
/>
</EuiFormRow>
</>
)}
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'ruleNameConfig') && (
<>
<EuiFormRow
id="ruleNameConfig"
fullWidth
label={i18n.SW_RULE_NAME_FIELD_LABEL}
error={mappingErrors?.ruleNameConfig}
isInvalid={mappingErrors?.ruleNameConfig != null && !hasChangedConnectorType}
>
<EuiComboBox
fullWidth
selectedOptions={state.ruleNameConfig}
options={textOptions}
singleSelection={SINGLE_SELECTION}
data-test-subj="swimlaneAlertNameInput"
onChange={(e) => editMappings('ruleNameConfig', e)}
isInvalid={mappingErrors?.ruleNameConfig != null && !hasChangedConnectorType}
/>
</EuiFormRow>
</>
)}
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'severityConfig') && (
<>
<EuiFormRow
id="severityConfig"
fullWidth
label={i18n.SW_SEVERITY_FIELD_LABEL}
error={mappingErrors?.severityConfig}
isInvalid={mappingErrors?.severityConfig != null && !hasChangedConnectorType}
>
<EuiComboBox
fullWidth
selectedOptions={state.severityConfig}
options={textOptions}
singleSelection={SINGLE_SELECTION}
data-test-subj="swimlaneSeverityInput"
onChange={(e) => editMappings('severityConfig', e)}
isInvalid={mappingErrors?.severityConfig != null && !hasChangedConnectorType}
/>
</EuiFormRow>
</>
)}
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'caseIdConfig') && (
<>
<EuiFormRow
id="caseIdConfig"
fullWidth
label={i18n.SW_CASE_ID_FIELD_LABEL}
error={mappingErrors?.caseIdConfig}
isInvalid={mappingErrors?.caseIdConfig != null && !hasChangedConnectorType}
>
<EuiComboBox
fullWidth
selectedOptions={state.caseIdConfig}
options={textOptions}
singleSelection={SINGLE_SELECTION}
data-test-subj="swimlaneCaseIdConfig"
onChange={(e) => editMappings('caseIdConfig', e)}
isInvalid={mappingErrors?.caseIdConfig != null && !hasChangedConnectorType}
/>
</EuiFormRow>
</>
)}
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'caseNameConfig') && (
<>
<EuiFormRow
id="caseNameConfig"
fullWidth
label={i18n.SW_CASE_NAME_FIELD_LABEL}
error={mappingErrors?.caseNameConfig}
isInvalid={mappingErrors?.caseNameConfig != null && !hasChangedConnectorType}
>
<EuiComboBox
fullWidth
selectedOptions={state.caseNameConfig}
options={textOptions}
singleSelection={SINGLE_SELECTION}
data-test-subj="swimlaneCaseNameConfig"
onChange={(e) => editMappings('caseNameConfig', e)}
isInvalid={mappingErrors?.caseNameConfig != null && !hasChangedConnectorType}
/>
</EuiFormRow>
</>
)}
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'commentsConfig') && (
<>
<EuiFormRow
id="commentsConfig"
fullWidth
label={i18n.SW_COMMENTS_FIELD_LABEL}
error={mappingErrors?.commentsConfig}
isInvalid={mappingErrors?.commentsConfig != null && !hasChangedConnectorType}
>
<EuiComboBox
fullWidth
selectedOptions={state.commentsConfig}
options={commentsOptions}
singleSelection={SINGLE_SELECTION}
data-test-subj="swimlaneCommentsConfig"
onChange={(e) => editMappings('commentsConfig', e)}
isInvalid={mappingErrors?.commentsConfig != null && !hasChangedConnectorType}
/>
</EuiFormRow>
</>
)}
{isValidFieldForConnector(connectorType as SwimlaneConnectorType, 'descriptionConfig') && (
<>
<EuiFormRow
id="descriptionConfig"
fullWidth
label={i18n.SW_DESCRIPTION_FIELD_LABEL}
error={mappingErrors?.descriptionConfig}
isInvalid={mappingErrors?.descriptionConfig != null && !hasChangedConnectorType}
>
<EuiComboBox
fullWidth
selectedOptions={state.descriptionConfig}
options={textOptions}
singleSelection={SINGLE_SELECTION}
data-test-subj="swimlaneDescriptionConfig"
onChange={(e) => editMappings('descriptionConfig', e)}
isInvalid={mappingErrors?.descriptionConfig != null && !hasChangedConnectorType}
/>
</EuiFormRow>
</>
)}
<EuiButton onClick={resetConnection}>{i18n.SW_CONFIGURE_API_LABEL}</EuiButton>
</>
);
};
export const SwimlaneFields = React.memo(SwimlaneFieldsComponent);

View file

@ -0,0 +1,219 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { SwimlaneActionConnector } from './types';
const ACTION_TYPE_ID = '.swimlane';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;
}
});
describe('actionTypeRegistry.get() works', () => {
test('action type static data is as expected', () => {
expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID);
});
});
describe('swimlane connector validation', () => {
test('connector validation succeeds when connector is valid', async () => {
const actionConnector = {
secrets: {
apiToken: 'test',
},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {
apiUrl: 'http:\\test',
appId: '1234567asbd32',
connectorType: 'all',
mappings: {
alertIdConfig: { id: '1234' },
severityConfig: { id: '1234' },
ruleNameConfig: { id: '1234' },
caseIdConfig: { id: '1234' },
caseNameConfig: { id: '1234' },
descriptionConfig: { id: '1234' },
commentsConfig: { id: '1234' },
},
},
} as SwimlaneActionConnector;
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: { errors: { apiUrl: [], appId: [], mappings: [], connectorType: [] } },
secrets: { errors: { apiToken: [] } },
});
});
test('it validates correctly when connectorType=all', async () => {
const actionConnector = {
secrets: {
apiToken: 'test',
},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {
apiUrl: 'http:\\test',
appId: '1234567asbd32',
connectorType: 'all',
mappings: {},
},
} as SwimlaneActionConnector;
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: { errors: { apiUrl: [], appId: [], mappings: [], connectorType: [] } },
secrets: { errors: { apiToken: [] } },
});
});
test('it validates correctly when connectorType=cases', async () => {
const actionConnector = {
secrets: {
apiToken: 'test',
},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {
apiUrl: 'http:\\test',
appId: '1234567asbd32',
connectorType: 'cases',
mappings: {},
},
} as SwimlaneActionConnector;
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: [],
appId: [],
mappings: [
{
caseIdConfig: 'Case ID is required.',
caseNameConfig: 'Case name is required.',
commentsConfig: 'Comments are required.',
descriptionConfig: 'Description is required.',
},
],
connectorType: [],
},
},
secrets: { errors: { apiToken: [] } },
});
});
test('it validates correctly when connectorType=alerts', async () => {
const actionConnector = {
secrets: {
apiToken: 'test',
},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {
apiUrl: 'http:\\test',
appId: '1234567asbd32',
connectorType: 'alerts',
mappings: {},
},
} as SwimlaneActionConnector;
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: [],
appId: [],
mappings: [
{
alertIdConfig: 'Alert ID is required.',
ruleNameConfig: 'Rule name is required.',
},
],
connectorType: [],
},
},
secrets: { errors: { apiToken: [] } },
});
});
test('it validates correctly required config/secrets fields', async () => {
const actionConnector = {
secrets: {},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {},
} as SwimlaneActionConnector;
expect(await actionTypeModel.validateConnector(actionConnector)).toEqual({
config: {
errors: {
apiUrl: ['URL is required.'],
appId: ['An App ID is required.'],
mappings: [],
connectorType: [],
},
},
secrets: { errors: { apiToken: ['An API token is required.'] } },
});
});
});
describe('swimlane action params validation', () => {
test('action params validation succeeds when action params is valid', async () => {
const actionParams = {
subActionParams: {
ruleName: 'Rule Name',
alertId: 'alert-id',
},
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
'subActionParams.incident.ruleName': [],
'subActionParams.incident.alertId': [],
},
});
});
test('it validates correctly required fields', async () => {
const actionParams = {
subActionParams: { incident: {} },
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
'subActionParams.incident.ruleName': ['Rule name is required.'],
'subActionParams.incident.alertId': ['Alert ID is required.'],
},
});
});
test('it succeeds when missing incident', async () => {
const actionParams = {
subActionParams: {},
};
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
'subActionParams.incident.ruleName': [],
'subActionParams.incident.alertId': [],
},
});
});
});

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 { isEmpty } from 'lodash';
import { lazy } from 'react';
import {
ActionTypeModel,
ConnectorValidationResult,
GenericValidationResult,
} from '../../../../types';
import {
SwimlaneActionConnector,
SwimlaneConfig,
SwimlaneSecrets,
SwimlaneActionParams,
} from './types';
import * as i18n from './translations';
import { isValidUrl } from '../../../lib/value_validators';
import { validateMappingForConnector } from './helpers';
export function getActionType(): ActionTypeModel<
SwimlaneConfig,
SwimlaneSecrets,
SwimlaneActionParams
> {
return {
id: '.swimlane',
iconClass: lazy(() => import('./logo')),
selectMessage: i18n.SW_SELECT_MESSAGE_TEXT,
actionTypeTitle: i18n.SW_ACTION_TYPE_TITLE,
validateConnector: async (
action: SwimlaneActionConnector
): Promise<ConnectorValidationResult<SwimlaneConfig, SwimlaneSecrets>> => {
const configErrors = {
apiUrl: new Array<string>(),
appId: new Array<string>(),
connectorType: new Array<string>(),
mappings: new Array<Record<string, string>>(),
};
const secretsErrors = {
apiToken: new Array<string>(),
};
const validationResult = {
config: { errors: configErrors },
secrets: { errors: secretsErrors },
};
if (!action.config.apiUrl) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.SW_API_URL_REQUIRED];
} else if (action.config.apiUrl) {
if (!isValidUrl(action.config.apiUrl)) {
configErrors.apiUrl = [...configErrors.apiUrl, i18n.SW_API_URL_INVALID];
}
}
if (!action.secrets.apiToken) {
secretsErrors.apiToken = [...secretsErrors.apiToken, i18n.SW_REQUIRED_API_TOKEN_TEXT];
}
if (!action.config.appId) {
configErrors.appId = [...configErrors.appId, i18n.SW_REQUIRED_APP_ID_TEXT];
}
const mappingErrors = validateMappingForConnector(
action.config.connectorType,
action.config.mappings
);
if (!isEmpty(mappingErrors)) {
configErrors.mappings = [...configErrors.mappings, mappingErrors];
}
return validationResult;
},
validateParams: async (
actionParams: SwimlaneActionParams
): Promise<GenericValidationResult<unknown>> => {
const errors = {
'subActionParams.incident.ruleName': new Array<string>(),
'subActionParams.incident.alertId': new Array<string>(),
};
const validationResult = {
errors,
};
const hasIncident = actionParams.subActionParams && actionParams.subActionParams.incident;
if (hasIncident && !actionParams.subActionParams.incident.ruleName?.length) {
errors['subActionParams.incident.ruleName'].push(i18n.SW_REQUIRED_RULE_NAME);
}
if (hasIncident && !actionParams.subActionParams.incident.alertId?.length) {
errors['subActionParams.incident.alertId'].push(i18n.SW_REQUIRED_ALERT_ID);
}
return validationResult;
},
actionConnectorFields: lazy(() => import('./swimlane_connectors')),
actionParamsFields: lazy(() => import('./swimlane_params')),
};
}

View file

@ -0,0 +1,319 @@
/*
* 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, nextTick } from '@kbn/test/jest';
import { act } from 'react-dom/test-utils';
import { SwimlaneActionConnector } from './types';
import SwimlaneActionConnectorFields from './swimlane_connectors';
import { useGetApplication } from './use_get_application';
import { applicationFields, mappings } from './mocks';
jest.mock('../../../../common/lib/kibana');
jest.mock('./use_get_application');
const useGetApplicationMock = useGetApplication as jest.Mock;
const getApplication = jest.fn();
describe('SwimlaneActionConnectorFields renders', () => {
beforeAll(() => {
useGetApplicationMock.mockReturnValue({
getApplication,
isLoading: false,
});
});
test('all connector fields are rendered', async () => {
const actionConnector = {
secrets: {
apiToken: 'test',
},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {
apiUrl: 'http:\\test',
appId: '1234567asbd32',
connectorType: 'all',
mappings,
},
} as SwimlaneActionConnector;
const wrapper = mountWithIntl(
<SwimlaneActionConnectorFields
action={actionConnector}
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
/>
);
await act(async () => {
await nextTick();
wrapper.update();
});
expect(wrapper.find('[data-test-subj="swimlaneApiUrlInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneAppIdInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneApiTokenInput"]').exists()).toBeTruthy();
});
test('should display a message on create to remember credentials', () => {
const actionConnector = {
actionTypeId: '.swimlane',
secrets: {},
config: {},
} as SwimlaneActionConnector;
const wrapper = mountWithIntl(
<SwimlaneActionConnectorFields
action={actionConnector}
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
/>
);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toEqual(0);
});
test('should display a message on edit to re-enter credentials', () => {
const actionConnector = {
secrets: {
apiToken: 'test',
},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {
apiUrl: 'http:\\test',
appId: '1234567asbd32',
connectorType: 'all',
mappings,
},
} as SwimlaneActionConnector;
const wrapper = mountWithIntl(
<SwimlaneActionConnectorFields
action={actionConnector}
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
/>
);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0);
});
test('renders the mappings correctly - connector type all', async () => {
getApplication.mockResolvedValue({
fields: applicationFields,
});
const actionConnector = {
secrets: {
apiToken: 'test',
},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {
apiUrl: 'http:\\test',
appId: '1234567asbd32',
connectorType: 'all',
mappings,
},
} as SwimlaneActionConnector;
const wrapper = mountWithIntl(
<SwimlaneActionConnectorFields
action={actionConnector}
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
/>
);
await act(async () => {
wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click');
await nextTick();
wrapper.update();
});
expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeTruthy();
});
test('renders the mappings correctly - connector type cases', async () => {
getApplication.mockResolvedValue({
fields: applicationFields,
});
const actionConnector = {
secrets: {
apiToken: 'test',
},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {
apiUrl: 'http:\\test',
appId: '1234567asbd32',
connectorType: 'cases',
mappings,
},
} as SwimlaneActionConnector;
const wrapper = mountWithIntl(
<SwimlaneActionConnectorFields
action={actionConnector}
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
/>
);
await act(async () => {
wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click');
await nextTick();
wrapper.update();
});
expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeTruthy();
});
test('renders the mappings correctly - connector type alerts', async () => {
getApplication.mockResolvedValue({
fields: applicationFields,
});
const actionConnector = {
secrets: {
apiToken: 'test',
},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {
apiUrl: 'http:\\test',
appId: '1234567asbd32',
connectorType: 'alerts',
mappings,
},
} as SwimlaneActionConnector;
const wrapper = mountWithIntl(
<SwimlaneActionConnectorFields
action={actionConnector}
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
/>
);
await act(async () => {
wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click');
await nextTick();
wrapper.update();
});
expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneSeverityInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').exists()).toBeFalsy();
});
test('renders the correct options per field', async () => {
getApplication.mockResolvedValue({
fields: applicationFields,
});
const actionConnector = {
secrets: {
apiToken: 'test',
},
id: 'test',
actionTypeId: '.swimlane',
name: 'swimlane',
config: {
apiUrl: 'http:\\test',
appId: '1234567asbd32',
connectorType: 'all',
mappings,
},
} as SwimlaneActionConnector;
const textOptions = [
{ label: 'Alert Id (alert-id)', value: 'a6ide' },
{ label: 'Severity (severity)', value: 'adnlas' },
{ label: 'Rule Name (rule-name)', value: 'adnfls' },
{ label: 'Case Id (case-id-name)', value: 'a6sst' },
{ label: 'Case Name (case-name)', value: 'a6fst' },
{ label: 'Description (description)', value: 'a6fde' },
];
const commentOptions = [{ label: 'Comments (notes)', value: 'a6fdf' }];
const wrapper = mountWithIntl(
<SwimlaneActionConnectorFields
action={actionConnector}
errors={{ connectorType: [], appId: [], apiUrl: [], mappings: [], apiToken: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
/>
);
await act(async () => {
wrapper.find('[data-test-subj="swimlaneConfigureMapping"]').first().simulate('click');
await nextTick();
wrapper.update();
});
expect(wrapper.find('[data-test-subj="swimlaneAlertIdInput"]').first().prop('options')).toEqual(
textOptions
);
expect(
wrapper.find('[data-test-subj="swimlaneAlertNameInput"]').first().prop('options')
).toEqual(textOptions);
expect(
wrapper.find('[data-test-subj="swimlaneSeverityInput"]').first().prop('options')
).toEqual(textOptions);
expect(wrapper.find('[data-test-subj="swimlaneCaseIdConfig"]').first().prop('options')).toEqual(
textOptions
);
expect(
wrapper.find('[data-test-subj="swimlaneCaseNameConfig"]').first().prop('options')
).toEqual(textOptions);
expect(
wrapper.find('[data-test-subj="swimlaneCommentsConfig"]').first().prop('options')
).toEqual(commentOptions);
expect(
wrapper.find('[data-test-subj="swimlaneDescriptionConfig"]').first().prop('options')
).toEqual(textOptions);
});
});

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment, useCallback, useMemo, useState } from 'react';
import { EuiForm, EuiSpacer, EuiStepsHorizontal, EuiStepStatus } from '@elastic/eui';
import * as i18n from './translations';
import { ActionConnectorFieldsProps } from '../../../../types';
import { SwimlaneActionConnector, SwimlaneFieldMappingConfig } from './types';
import { SwimlaneConnection, SwimlaneFields } from './steps';
const SwimlaneActionConnectorFields: React.FunctionComponent<
ActionConnectorFieldsProps<SwimlaneActionConnector>
> = ({ errors, action, editActionConfig, editActionSecrets, readOnly }) => {
const [currentStep, setCurrentStep] = useState<number>(1);
const [stepsStatuses, setStepsStatuses] = useState<{
connection: EuiStepStatus;
fields: EuiStepStatus;
}>({ connection: 'incomplete', fields: 'incomplete' });
const [fields, setFields] = useState<SwimlaneFieldMappingConfig[]>([]);
const updateCurrentStep = useCallback(
(step: number) => {
setCurrentStep(step);
if (step === 2) {
setStepsStatuses((statuses) => ({ ...statuses, connection: 'complete' }));
} else if (step === 1) {
setStepsStatuses({
fields: 'incomplete',
connection: 'incomplete',
});
editActionConfig('mappings', action.config.mappings);
}
},
[action.config.mappings, editActionConfig]
);
const setupSteps = useMemo(
() => [
{
title: i18n.SW_CONFIGURE_CONNECTION_LABEL,
status: stepsStatuses.connection,
onClick: () => updateCurrentStep(1),
},
{
title: i18n.SW_MAPPING_TITLE_TEXT_FIELD_LABEL,
disabled: stepsStatuses.connection !== 'complete',
status: stepsStatuses.fields,
onClick: () => updateCurrentStep(2),
},
],
[stepsStatuses.connection, stepsStatuses.fields, updateCurrentStep]
);
const editActionConfigCb = useCallback(
(k: string, v: string) => {
editActionConfig(k, v);
if (
Object.values(errors?.mappings ?? {}).every((mappingError) => mappingError.length === 0)
) {
setStepsStatuses((statuses) => ({ ...statuses, fields: 'complete' }));
} else {
setStepsStatuses((statuses) => ({ ...statuses, fields: 'incomplete' }));
}
},
[editActionConfig, errors?.mappings]
);
return (
<Fragment>
<EuiStepsHorizontal steps={setupSteps} />
<EuiSpacer size="l" />
<EuiForm>
{currentStep === 1 && (
<SwimlaneConnection
action={action}
editActionConfig={editActionConfigCb}
editActionSecrets={editActionSecrets}
readOnly={readOnly}
errors={errors}
updateCurrentStep={updateCurrentStep}
updateFields={setFields}
/>
)}
{currentStep === 2 && (
<SwimlaneFields
action={action}
editActionConfig={editActionConfigCb}
updateCurrentStep={updateCurrentStep}
fields={fields}
errors={errors}
/>
)}
</EuiForm>
</Fragment>
);
};
// eslint-disable-next-line import/no-default-export
export { SwimlaneActionConnectorFields as default };

View file

@ -0,0 +1,137 @@
/*
* 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';
import SwimlaneParamsFields from './swimlane_params';
import { SwimlaneConnectorType } from './types';
import { mappings } from './mocks';
describe('SwimlaneParamsFields renders', () => {
const editAction = jest.fn();
const actionParams = {
subAction: 'pushToService',
subActionParams: {
incident: {
alertId: '3456789',
ruleName: 'rule name',
severity: 'critical',
caseId: null,
caseName: null,
description: null,
externalId: null,
},
comments: [],
},
};
const connector = {
secrets: {},
config: { mappings, connectorType: SwimlaneConnectorType.All },
id: 'test',
actionTypeId: '.test',
name: 'Test',
isPreconfigured: false,
};
const defaultProps = {
actionParams,
errors: {
'subActionParams.incident.ruleName': [],
'subActionParams.incident.alertId': [],
},
editAction,
index: 0,
messageVariables: [],
actionConnector: connector,
};
beforeEach(() => {
jest.clearAllMocks();
});
test('all params fields are rendered', () => {
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
expect(wrapper.find('[data-test-subj="severity"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="comments"]').exists()).toBeTruthy();
});
test('it set the correct default params', () => {
mountWithIntl(<SwimlaneParamsFields {...defaultProps} actionParams={{}} />);
expect(editAction).toHaveBeenCalledWith('subAction', 'pushToService', 0);
expect(editAction).toHaveBeenCalledWith(
'subActionParams',
{
incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' },
comments: [],
},
0
);
});
test('it reset the fields when connector changes', () => {
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
expect(editAction).not.toHaveBeenCalled();
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
expect(editAction).toHaveBeenCalledWith(
'subActionParams',
{
incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' },
comments: [],
},
0
);
});
test('it set the severity', () => {
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
expect(editAction).not.toHaveBeenCalled();
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
expect(editAction).toHaveBeenCalledWith(
'subActionParams',
{
incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' },
comments: [],
},
0
);
});
describe('UI updates', () => {
const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent<HTMLSelectElement>;
const simpleFields = [
{ dataTestSubj: 'input[data-test-subj="severityInput"]', key: 'severity' },
];
simpleFields.forEach((field) =>
test(`${field.key} update triggers editAction`, () => {
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
const theField = wrapper.find(field.dataTestSubj).first();
theField.prop('onChange')!(changeEvent);
expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value);
})
);
test('A comment triggers editAction', () => {
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]');
expect(comments.simulate('change', changeEvent));
expect(editAction.mock.calls[0][1].comments.length).toEqual(1);
});
test('An empty comment does not trigger editAction', () => {
const wrapper = mountWithIntl(<SwimlaneParamsFields {...defaultProps} />);
const emptyComment = { target: { value: '' } };
const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea');
expect(comments.simulate('change', emptyComment));
expect(editAction.mock.calls.length).toEqual(0);
});
});
});

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, { useCallback, useEffect, useRef, useMemo } from 'react';
import { EuiCallOut, EuiFormRow, EuiSpacer } from '@elastic/eui';
import * as i18n from './translations';
import { ActionParamsProps } from '../../../../types';
import { SwimlaneActionConnector, SwimlaneActionParams, SwimlaneConnectorType } from './types';
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
const SwimlaneParamsFields: React.FunctionComponent<ActionParamsProps<SwimlaneActionParams>> = ({
actionParams,
editAction,
index,
messageVariables,
actionConnector,
}) => {
const { incident, comments } = useMemo(
() =>
actionParams.subActionParams ??
(({
incident: {},
comments: [],
} as unknown) as SwimlaneActionParams['subActionParams']),
[actionParams.subActionParams]
);
const actionConnectorRef = useRef(actionConnector?.id ?? '');
const {
mappings,
connectorType,
} = ((actionConnector as unknown) as SwimlaneActionConnector).config;
const { hasAlertId, hasRuleName, hasComments, hasSeverity } = useMemo(
() => ({
hasAlertId: mappings.alertIdConfig != null,
hasRuleName: mappings.ruleNameConfig != null,
hasComments: mappings.commentsConfig != null,
hasSeverity: mappings.severityConfig != null,
}),
[
mappings.alertIdConfig,
mappings.ruleNameConfig,
mappings.commentsConfig,
mappings.severityConfig,
]
);
/**
* The user can use either a connector of type alerts or all.
* If the connector is of type all we should check if all
* required field have been configured.
*/
const showMappingWarning =
connectorType === SwimlaneConnectorType.Cases || !hasRuleName || !hasAlertId;
const editSubActionProperty = useCallback(
(key: string, value: any) => {
if (key === 'comments') {
return editAction('subActionParams', { incident, comments: value }, index);
}
return editAction(
'subActionParams',
{
incident: { ...incident, [key]: value },
comments,
},
index
);
},
[editAction, incident, comments, index]
);
const editComment = useCallback(
(key, value) => {
if (value.length > 0) {
editSubActionProperty(key, [{ commentId: '1', comment: value }]);
}
},
[editSubActionProperty]
);
useEffect(() => {
if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) {
actionConnectorRef.current = actionConnector.id;
editAction(
'subActionParams',
{
incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' },
comments: [],
},
index
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionConnector]);
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);
}
if (!actionParams.subActionParams) {
editAction(
'subActionParams',
{
incident: { alertId: '{{alert.id}}', ruleName: '{{rule.name}}' },
comments: [],
},
index
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionParams]);
return !showMappingWarning ? (
<>
{hasSeverity && (
<>
<EuiFormRow fullWidth label={i18n.SW_SEVERITY_FIELD_LABEL}>
<TextFieldWithMessageVariables
index={index}
data-test-subj="severity"
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'severity'}
inputTargetValue={incident.severity ?? undefined}
/>
</EuiFormRow>
<EuiSpacer size="m" />
</>
)}
{hasComments && (
<TextAreaWithMessageVariables
data-test-subj="comments"
index={index}
editAction={editComment}
messageVariables={messageVariables}
paramsProperty={'comments'}
inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined}
label={i18n.SW_COMMENTS_FIELD_LABEL}
/>
)}
</>
) : (
<EuiCallOut title={i18n.EMPTY_MAPPING_WARNING_TITLE} color="warning" iconType="help">
{i18n.EMPTY_MAPPING_WARNING_DESC}
</EuiCallOut>
);
};
// eslint-disable-next-line import/no-default-export
export { SwimlaneParamsFields as default };

View file

@ -0,0 +1,282 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SW_SELECT_MESSAGE_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.selectMessageText',
{
defaultMessage: 'Create record in Swimlane',
}
);
export const SW_ACTION_TYPE_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.actionTypeTitle',
{
defaultMessage: 'Create Swimlane Record',
}
);
export const SW_REQUIRED_RULE_NAME = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredRuleName',
{
defaultMessage: 'Rule name is required.',
}
);
export const SW_REQUIRED_APP_ID_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAppIdText',
{
defaultMessage: 'An App ID is required.',
}
);
export const SW_REQUIRED_FIELD_MAPPINGS_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredFieldMappingsText',
{
defaultMessage: 'Field mappings are required.',
}
);
export const SW_REQUIRED_API_TOKEN_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredApiTokenText',
{
defaultMessage: 'An API token is required.',
}
);
export const SW_GET_APPLICATION_API_ERROR = (id: string | null) =>
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlane.unableToGetApplicationMessage',
{
defaultMessage: 'Unable to get application with id {id}',
values: { id },
}
);
export const SW_GET_APPLICATION_API_NO_FIELDS_ERROR = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlane.unableToGetApplicationFieldsMessage',
{
defaultMessage: 'Unable to get application fields',
}
);
export const SW_API_URL_TEXT_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.apiUrlTextFieldLabel',
{
defaultMessage: 'API Url',
}
);
export const SW_API_URL_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.requiredApiUrlTextField',
{
defaultMessage: 'URL is required.',
}
);
export const SW_API_URL_INVALID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.invalidApiUrlTextField',
{
defaultMessage: 'URL is invalid.',
}
);
export const SW_APP_ID_TEXT_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.appIdTextFieldLabel',
{
defaultMessage: 'Application ID',
}
);
export const SW_API_TOKEN_TEXT_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.apiTokenTextFieldLabel',
{
defaultMessage: 'API Token',
}
);
export const SW_MAPPING_TITLE_TEXT_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingTitleTextFieldLabel',
{
defaultMessage: 'Configure Field Mappings',
}
);
export const SW_ALERT_SOURCE_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertSourceFieldLabel',
{
defaultMessage: 'Alert source',
}
);
export const SW_SEVERITY_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.severityFieldLabel',
{
defaultMessage: 'Severity',
}
);
export const SW_MAPPING_DESCRIPTION_TEXT_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingDescriptionTextFieldLabel',
{
defaultMessage: 'Used to specify the field names in the Swimlane Application',
}
);
export const SW_RULE_NAME_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.ruleNameFieldLabel',
{
defaultMessage: 'Rule name',
}
);
export const SW_ALERT_ID_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertIdFieldLabel',
{
defaultMessage: 'Alert ID',
}
);
export const SW_CASE_ID_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.caseIdFieldLabel',
{
defaultMessage: 'Case ID',
}
);
export const SW_CASE_NAME_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.caseNameFieldLabel',
{
defaultMessage: 'Case name',
}
);
export const SW_COMMENTS_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.commentsFieldLabel',
{
defaultMessage: 'Comments',
}
);
export const SW_DESCRIPTION_FIELD_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.descriptionFieldLabel',
{
defaultMessage: 'Description',
}
);
export const SW_REMEMBER_VALUE_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.rememberValueLabel',
{ defaultMessage: 'Remember this value. You must reenter it each time you edit the connector.' }
);
export const SW_REENTER_VALUE_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.reenterValueLabel',
{ defaultMessage: 'This key is encrypted. Please reenter a value for this field.' }
);
export const SW_CONFIGURE_CONNECTION_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.configureConnectionLabel',
{ defaultMessage: 'Configure API Connection' }
);
export const SW_RETRIEVE_CONFIGURATION_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.retrieveConfigurationLabel',
{ defaultMessage: 'Configure Fields' }
);
export const SW_CONFIGURE_API_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.configureAPILabel',
{ defaultMessage: 'Configure API' }
);
export const SW_CONNECTOR_TYPE_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.connectorType',
{
defaultMessage: 'Connector Type',
}
);
export const SW_FIELD_MAPPING_IS_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.mappingFieldRequired',
{
defaultMessage: 'Field mapping is required.',
}
);
export const EMPTY_MAPPING_WARNING_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.emptyMappingWarningTitle',
{
defaultMessage: 'This connector has missing field mappings',
}
);
export const EMPTY_MAPPING_WARNING_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.emptyMappingWarningDesc',
{
defaultMessage:
'This connector cannot be selected because it is missing the required case field mappings. You can edit this connector to add required field mappings or select a connector of type Alerts.',
}
);
export const SW_REQUIRED_ALERT_SOURCE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAlertSource',
{
defaultMessage: 'Alert source is required.',
}
);
export const SW_REQUIRED_SEVERITY = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredSeverity',
{
defaultMessage: 'Severity is required.',
}
);
export const SW_REQUIRED_CASE_NAME = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredCaseName',
{
defaultMessage: 'Case name is required.',
}
);
export const SW_REQUIRED_CASE_ID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredCaseID',
{
defaultMessage: 'Case ID is required.',
}
);
export const SW_REQUIRED_COMMENTS = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredComments',
{
defaultMessage: 'Comments are required.',
}
);
export const SW_REQUIRED_DESCRIPTION = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredDescription',
{
defaultMessage: 'Description is required.',
}
);
export const SW_REQUIRED_ALERT_ID = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.error.requiredAlertID',
{
defaultMessage: 'Alert ID is required.',
}
);
export const SW_ALERT_SOURCE_TOOLTIP = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.swimlaneAction.alertSourceTooltip',
{
defaultMessage: 'The index of the alert. Use {index} in Detections.',
values: { index: '{{context.rule.output_index}}' },
}
);

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/* eslint-disable @kbn/eslint/no-restricted-paths */
import { UserConfiguredActionConnector } from '../../../../types';
import {
ExecutorSubActionPushParams,
MappingConfigType,
} from '../../../../../../actions/server/builtin_action_types/swimlane/types';
export type SwimlaneActionConnector = UserConfiguredActionConnector<
SwimlaneConfig,
SwimlaneSecrets
>;
export interface SwimlaneConfig {
apiUrl: string;
appId: string;
connectorType: SwimlaneConnectorType;
mappings: SwimlaneMappingConfig;
}
export type MappingConfigurationKeys = keyof MappingConfigType;
export type SwimlaneMappingConfig = Record<keyof MappingConfigType, SwimlaneFieldMappingConfig>;
export interface SwimlaneFieldMappingConfig {
id: string;
key: string;
name: string;
fieldType: string;
}
export interface SwimlaneSecrets {
apiToken: string;
}
export interface SwimlaneActionParams {
subAction: string;
subActionParams: ExecutorSubActionPushParams;
}
export interface SwimlaneFieldMap {
key: string;
name: string;
}
export enum SwimlaneConnectorType {
All = 'all',
Alerts = 'alerts',
Cases = 'cases',
}

View file

@ -0,0 +1,180 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { useKibana } from '../../../../common/lib/kibana';
import { getApplication } from './api';
import { SwimlaneActionConnector } from './types';
import { useGetApplication, UseGetApplication } from './use_get_application';
jest.mock('./api');
jest.mock('../../../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const getApplicationMock = getApplication as jest.Mock;
const action = {
secrets: { apiToken: 'token' },
id: 'test',
actionTypeId: '.swimlane',
name: 'Swimlane',
isPreconfigured: false,
config: {
apiUrl: 'https://test.swimlane.com/',
appId: 'bcq16kdTbz5jlwM6h',
mappings: {},
},
} as SwimlaneActionConnector;
describe('useGetApplication', () => {
const { services } = useKibanaMock();
getApplicationMock.mockResolvedValue({
data: { fields: [] },
});
const abortCtrl = new AbortController();
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
useGetApplication({
appId: action.config.appId,
apiToken: action.secrets.apiToken,
apiUrl: action.config.apiUrl,
toastNotifications: services.notifications.toasts,
})
);
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
getApplication: result.current.getApplication,
});
});
});
it('calls getApplication with correct arguments', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
useGetApplication({
appId: action.config.appId,
apiToken: action.secrets.apiToken,
apiUrl: action.config.apiUrl,
toastNotifications: services.notifications.toasts,
})
);
await waitForNextUpdate();
result.current.getApplication();
await waitForNextUpdate();
expect(getApplicationMock).toBeCalledWith({
signal: abortCtrl.signal,
appId: action.config.appId,
apiToken: action.secrets.apiToken,
url: action.config.apiUrl,
});
});
});
it('get application', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
useGetApplication({
appId: action.config.appId,
apiToken: action.secrets.apiToken,
apiUrl: action.config.apiUrl,
toastNotifications: services.notifications.toasts,
})
);
await waitForNextUpdate();
result.current.getApplication();
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
getApplication: result.current.getApplication,
});
});
});
it('set isLoading to true when getting the application', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
useGetApplication({
appId: action.config.appId,
apiToken: action.secrets.apiToken,
apiUrl: action.config.apiUrl,
toastNotifications: services.notifications.toasts,
})
);
await waitForNextUpdate();
result.current.getApplication();
expect(result.current.isLoading).toBe(true);
});
});
it('it displays an error when http throws an error', async () => {
getApplicationMock.mockImplementation(() => {
throw new Error('Something went wrong');
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
useGetApplication({
appId: action.config.appId,
apiToken: action.secrets.apiToken,
apiUrl: action.config.apiUrl,
toastNotifications: services.notifications.toasts,
})
);
await waitForNextUpdate();
result.current.getApplication();
expect(result.current).toEqual({
isLoading: false,
getApplication: result.current.getApplication,
});
expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({
title: 'Unable to get application with id bcq16kdTbz5jlwM6h',
text: 'Something went wrong',
});
});
});
it('it displays an error when the response does not contain the correct fields', async () => {
getApplicationMock.mockResolvedValue({});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetApplication>(() =>
useGetApplication({
appId: action.config.appId,
apiToken: action.secrets.apiToken,
apiUrl: action.config.apiUrl,
toastNotifications: services.notifications.toasts,
})
);
await waitForNextUpdate();
result.current.getApplication();
await waitForNextUpdate();
expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({
title: 'Unable to get application with id bcq16kdTbz5jlwM6h',
text: 'Unable to get application fields',
});
});
});
});

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useState, useCallback, useRef } from 'react';
import { ToastsApi } from 'kibana/public';
import { getApplication as getApplicationApi } from './api';
import * as i18n from './translations';
import { SwimlaneFieldMappingConfig } from './types';
interface Props {
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
appId: string;
apiToken: string;
apiUrl: string;
}
export interface UseGetApplication {
getApplication: () => Promise<{ fields?: SwimlaneFieldMappingConfig[] } | undefined>;
isLoading: boolean;
}
export const useGetApplication = ({
toastNotifications,
appId,
apiToken,
apiUrl,
}: Props): UseGetApplication => {
const [isLoading, setIsLoading] = useState(false);
const isCancelledRef = useRef(false);
const abortCtrlRef = useRef(new AbortController());
const getApplication = useCallback(async () => {
try {
isCancelledRef.current = false;
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
setIsLoading(true);
const data = await getApplicationApi({
signal: abortCtrlRef.current.signal,
appId,
apiToken,
url: apiUrl,
});
if (!isCancelledRef.current) {
setIsLoading(false);
if (!data.fields) {
// If the response was malformed and fields doesn't exist, show an error toast
toastNotifications.addDanger({
title: i18n.SW_GET_APPLICATION_API_ERROR(appId),
text: i18n.SW_GET_APPLICATION_API_NO_FIELDS_ERROR,
});
return;
}
return data;
}
} catch (error) {
if (!isCancelledRef.current) {
if (error.name !== 'AbortError') {
toastNotifications.addDanger({
title: i18n.SW_GET_APPLICATION_API_ERROR(appId),
text: error.message,
});
}
setIsLoading(false);
}
}
}, [apiToken, apiUrl, appId, toastNotifications]);
return {
isLoading,
getApplication,
};
};

Some files were not shown because too many files have changed in this diff Show more