diff --git a/x-pack/legacy/plugins/siem/index.ts b/x-pack/legacy/plugins/siem/index.ts index db398821aecfd..3773283555b32 100644 --- a/x-pack/legacy/plugins/siem/index.ts +++ b/x-pack/legacy/plugins/siem/index.ts @@ -40,7 +40,7 @@ export const siem = (kibana: any) => { id: APP_ID, configPrefix: 'xpack.siem', publicDir: resolve(__dirname, 'public'), - require: ['kibana', 'elasticsearch', 'alerting', 'actions'], + require: ['kibana', 'elasticsearch', 'alerting', 'actions', 'triggers_actions_ui'], uiExports: { app: { description: i18n.translate('xpack.siem.securityDescription', { diff --git a/x-pack/legacy/plugins/siem/public/lib/connectors/config.ts b/x-pack/legacy/plugins/siem/public/lib/connectors/config.ts new file mode 100644 index 0000000000000..0744675997d9c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/lib/connectors/config.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Connector } from './types'; +import serviceNowLogo from './logos/servicenow.svg'; + +const connectors = new Map(); + +connectors.set('.servicenow', { + actionTypeId: '.servicenow', + logo: serviceNowLogo, +}); + +export { connectors }; diff --git a/x-pack/legacy/plugins/siem/public/lib/connectors/index.ts b/x-pack/legacy/plugins/siem/public/lib/connectors/index.ts new file mode 100644 index 0000000000000..fdf337b5ef120 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/lib/connectors/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { getActionType as serviceNowActionType } from './servicenow'; diff --git a/x-pack/legacy/plugins/siem/public/lib/connectors/logos/servicenow.svg b/x-pack/legacy/plugins/siem/public/lib/connectors/logos/servicenow.svg new file mode 100755 index 0000000000000..dcd022a8dca18 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/lib/connectors/logos/servicenow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/x-pack/legacy/plugins/siem/public/lib/connectors/servicenow.tsx b/x-pack/legacy/plugins/siem/public/lib/connectors/servicenow.tsx new file mode 100644 index 0000000000000..a2733322acdf5 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/lib/connectors/servicenow.tsx @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldPassword, + EuiRadioGroup, +} from '@elastic/eui'; +import { + ActionConnectorFieldsProps, + ActionTypeModel, + ValidationResult, + ActionParamsProps, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../plugins/triggers_actions_ui/public/types'; + +import * as i18n from './translations'; + +import { ServiceNowActionConnector } from './types'; +import { isUrlInvalid } from './validators'; + +import { connectors } from './config'; + +const serviceNowDefinition = connectors.get('.servicenow')!; + +interface ServiceNowActionParams { + message: string; +} + +export function getActionType(): ActionTypeModel { + return { + id: serviceNowDefinition.actionTypeId, + iconClass: serviceNowDefinition.logo, + selectMessage: i18n.SERVICENOW_DESC, + actionTypeTitle: i18n.SERVICENOW_TITLE, + validateConnector: (action: ServiceNowActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + apiUrl: [] as string[], + username: [] as string[], + password: [] as string[], + }; + + if (!action.config.apiUrl) { + errors.apiUrl.push(i18n.SERVICENOW_API_URL_REQUIRED); + } + + if (isUrlInvalid(action.config.apiUrl)) { + errors.apiUrl.push(i18n.SERVICENOW_API_URL_INVALID); + } + + if (!action.secrets.username) { + errors.username.push(i18n.SERVICENOW_USERNAME_REQUIRED); + } + + if (!action.secrets.password) { + errors.password.push(i18n.SERVICENOW_PASSWORD_REQUIRED); + } + + validationResult.errors = errors; + return validationResult; + }, + validateParams: (actionParams: ServiceNowActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: [] as string[], + }; + validationResult.errors = errors; + return validationResult; + }, + actionConnectorFields: ServiceNowConnectorFields, + actionParamsFields: ServiceNowParamsFields, + }; +} + +const ServiceNowConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { + const { apiUrl, casesConfiguration } = action.config; + const { username, password } = action.secrets; + + const closure = casesConfiguration?.closure ?? 'manual'; + + const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl !== undefined; + const isUsernameInvalid: boolean = errors.username.length > 0 && username !== undefined; + const isPasswordInvalid: boolean = errors.password.length > 0 && password !== undefined; + + if (!casesConfiguration) { + editActionConfig('casesConfiguration', { + closure: 'manual', + mapping: [ + { + source: 'title', + target: 'description', + onEditAndUpdate: 'nothing', + }, + { + source: 'description', + target: 'short_description', + onEditAndUpdate: 'nothing', + }, + { + source: 'comments', + target: 'work_notes', + onEditAndUpdate: 'nothing', + }, + ], + }); + } + + const radios = [ + { + id: 'manual', + label: 'Manually close SIEM cases', + }, + { + id: 'newIncident', + label: 'Automatically close SIEM cases when pushing new incident to third-party', + }, + { + id: 'closedIncident', + label: 'Automatically close SIEM cases when incident is closed in third-party', + }, + ]; + + return ( + <> + + + + { + editActionConfig('apiUrl', e.target.value); + }} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + /> + + + + + + + { + editActionSecrets('username', e.target.value); + }} + onBlur={() => { + if (!username) { + editActionSecrets('username', ''); + } + }} + /> + + + + + + + { + editActionSecrets('password', e.target.value); + }} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + /> + + + + + + + { + editActionConfig('casesConfiguration', { ...casesConfiguration, closure: val }); + }} + name="closure" + /> + + + + + ); +}; + +const ServiceNowParamsFields: React.FunctionComponent> = ({ actionParams, editAction, index, errors }) => { + return null; +}; diff --git a/x-pack/legacy/plugins/siem/public/lib/connectors/translations.ts b/x-pack/legacy/plugins/siem/public/lib/connectors/translations.ts new file mode 100644 index 0000000000000..ae2084120255c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/lib/connectors/translations.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const SERVICENOW_DESC = i18n.translate( + 'xpack.siem.case.connectors.servicenow.selectMessageText', + { + defaultMessage: 'Push or update SIEM case data to a new incident in ServiceNow', + } +); + +export const SERVICENOW_TITLE = i18n.translate( + 'xpack.siem.case.connectors.servicenow.actionTypeTitle', + { + defaultMessage: 'ServiceNow', + } +); + +export const SERVICENOW_API_URL_LABEL = i18n.translate( + 'xpack.siem.case.connectors.servicenow.apiUrlTextFieldLabel', + { + defaultMessage: 'URL', + } +); + +export const SERVICENOW_API_URL_REQUIRED = i18n.translate( + 'xpack.siem.case.connectors.servicenow.requiredApiUrlTextField', + { + defaultMessage: 'URL is required', + } +); + +export const SERVICENOW_API_URL_INVALID = i18n.translate( + 'xpack.siem.case.connectors.servicenow.invalidApiUrlTextField', + { + defaultMessage: 'URL is invalid', + } +); + +export const SERVICENOW_USERNAME_LABEL = i18n.translate( + 'xpack.siem.case.connectors.servicenow.usernameTextFieldLabel', + { + defaultMessage: 'Username', + } +); + +export const SERVICENOW_USERNAME_REQUIRED = i18n.translate( + 'xpack.siem.case.connectors.servicenow.requiredUsernameTextField', + { + defaultMessage: 'Username is required', + } +); + +export const SERVICENOW_PASSWORD_LABEL = i18n.translate( + 'xpack.siem.case.connectors.servicenow.passwordTextFieldLabel', + { + defaultMessage: 'Password', + } +); + +export const SERVICENOW_PASSWORD_REQUIRED = i18n.translate( + 'xpack.siem.case.connectors.servicenow.requiredPasswordTextField', + { + defaultMessage: 'Password is required', + } +); diff --git a/x-pack/legacy/plugins/siem/public/lib/connectors/types.ts b/x-pack/legacy/plugins/siem/public/lib/connectors/types.ts new file mode 100644 index 0000000000000..a8f5e47e05d72 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/lib/connectors/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface ServiceNowConfig { + apiUrl: string; + casesConfiguration: { + closure: string; + }; +} + +interface ServiceNowSecrets { + username: string; + password: string; +} + +export interface ServiceNowActionConnector { + config: ServiceNowConfig; + secrets: ServiceNowSecrets; +} + +export interface Connector { + actionTypeId: string; + logo: string; +} diff --git a/x-pack/legacy/plugins/siem/public/lib/connectors/validators.ts b/x-pack/legacy/plugins/siem/public/lib/connectors/validators.ts new file mode 100644 index 0000000000000..2989cf4d98f85 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/lib/connectors/validators.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { isUrlInvalid } from '../../utils/validators'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx index 561464e44c703..4146bfef291b6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiDescribedFormGroup, EuiFormRow, @@ -18,6 +18,12 @@ import styled from 'styled-components'; import { ConnectorsDropdown } from './connectors_dropdown'; import * as i18n from './translations'; +import { + ActionsConnectorsContextProvider, + ConnectorAddFlyout, +} from '../../../../../../../../plugins/triggers_actions_ui/public'; +import { useKibana } from '../../../../lib/kibana'; + const EuiFormRowExtended = styled(EuiFormRow)` .euiFormRow__labelWrapper { .euiFormRow__label { @@ -27,25 +33,43 @@ const EuiFormRowExtended = styled(EuiFormRow)` `; const ConnectorsComponent: React.FC = () => { + const { http, triggers_actions_ui, notifications, application } = useKibana().services; + const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); + const dropDownLabel = ( {i18n.INCIDENT_MANAGEMENT_SYSTEM_LABEL} - {i18n.ADD_NEW_CONNECTOR} + setAddFlyoutVisibility(true)}>{i18n.ADD_NEW_CONNECTOR} ); return ( - {i18n.INCIDENT_MANAGEMENT_SYSTEM_TITLE}} - description={i18n.INCIDENT_MANAGEMENT_SYSTEM_DESC} - > - - - - + <> + {i18n.INCIDENT_MANAGEMENT_SYSTEM_TITLE}} + description={i18n.INCIDENT_MANAGEMENT_SYSTEM_DESC} + > + + + + + + + + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx index d43935deda395..3c88afe186bb0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/connectors_dropdown.tsx @@ -10,7 +10,10 @@ import styled from 'styled-components'; import * as i18n from './translations'; +import { connectors as connectorsDefinition } from '../../../../lib/connectors/config'; + const ICON_SIZE = 'm'; +const serviceNowDefinition = connectorsDefinition.get('.servicenow')!; const EuiIconExtended = styled(EuiIcon)` margin-right: 13px; @@ -31,7 +34,7 @@ const connectors: Array> = [ value: 'servicenow-connector', inputDisplay: ( <> - + {'My ServiceNow connector'} ), diff --git a/x-pack/legacy/plugins/siem/public/plugin.tsx b/x-pack/legacy/plugins/siem/public/plugin.tsx index 8be5510cda83a..f22add59a95d4 100644 --- a/x-pack/legacy/plugins/siem/public/plugin.tsx +++ b/x-pack/legacy/plugins/siem/public/plugin.tsx @@ -21,11 +21,19 @@ import { UsageCollectionSetup } from '../../../../../src/plugins/usage_collectio import { initTelemetry } from './lib/telemetry'; import { KibanaServices } from './lib/kibana'; +import { serviceNowActionType } from './lib/connectors'; + +import { + TriggersAndActionsUIPublicPluginSetup, + TriggersAndActionsUIPublicPluginStart, +} from '../../../../plugins/triggers_actions_ui/public'; + export { AppMountParameters, CoreSetup, CoreStart, PluginInitializerContext }; export interface SetupPlugins { home: HomePublicPluginSetup; usageCollection: UsageCollectionSetup; + triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; } export interface StartPlugins { data: DataPublicPluginStart; @@ -33,6 +41,7 @@ export interface StartPlugins { inspector: InspectorStart; newsfeed?: NewsfeedStart; uiActions: UiActionsStart; + triggers_actions_ui: TriggersAndActionsUIPublicPluginStart; } export type StartServices = CoreStart & StartPlugins; @@ -59,6 +68,8 @@ export class Plugin implements IPlugin { const [coreStart, startPlugins] = await core.getStartServices(); const { renderApp } = await import('./app'); + plugins.triggers_actions_ui.actionTypeRegistry.register(serviceNowActionType()); + return renderApp(coreStart, startPlugins as StartPlugins, params); }, }); diff --git a/x-pack/legacy/plugins/siem/public/utils/validators/index.ts b/x-pack/legacy/plugins/siem/public/utils/validators/index.ts new file mode 100644 index 0000000000000..99b01c8b22974 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/utils/validators/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash/fp'; + +const urlExpression = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi; + +export const isUrlInvalid = (url: string | null | undefined) => { + if (!isEmpty(url) && url != null && url.match(urlExpression) == null) { + return true; + } + return false; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts index cfd3a9d70dc93..795b649e42df7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts @@ -6,7 +6,7 @@ import axios, { AxiosResponse } from 'axios'; import { Services } from '../../types'; -import { ParamsType, SecretsType } from '../servicenow'; +import { ParamsType, SecretsType } from '../servicenow/types'; interface PostServiceNowOptions { apiUrl: string; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/constants.ts new file mode 100644 index 0000000000000..5526fa413a44a --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/constants.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const API_VERSION = 'v1'; +export const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; +export const USER_URL = `api/now/${API_VERSION}/table/sys_user?user_name=`; +export const COMMENT_URL = `api/now/${API_VERSION}/table/sys_journal_field`; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/index.test.ts new file mode 100644 index 0000000000000..50a65407426aa --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/index.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import axios from 'axios'; +import { ServiceNow } from '.'; +import { + instance, + incident, + axiosResponse, + userIdResponse, + incidentAxiosResponse, + incidentResponse, + params, +} from '../../servicenow/mock'; +import { USER_URL, INCIDENT_URL } from './constants'; + +jest.mock('axios'); + +axios.create = jest.fn(() => axios); +const axiosMock = (axios as unknown) as jest.Mock; + +let serviceNow: ServiceNow; + +const testMissingConfiguration = (field: string) => { + expect.assertions(1); + try { + new ServiceNow({ ...instance, [field]: '' }); + } catch (error) { + expect(error.message).toEqual('[Action][ServiceNow]: Wrong configuration.'); + } +}; + +const prependInstanceUrl = (url: string): string => `${instance.url}/${url}`; + +describe('ServiceNow lib', () => { + beforeEach(() => { + serviceNow = new ServiceNow(instance); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should thrown an error if url is missing', () => { + testMissingConfiguration('url'); + }); + + test('should thrown an error if username is missing', () => { + testMissingConfiguration('username'); + }); + + test('should thrown an error if password is missing', () => { + testMissingConfiguration('password'); + }); + + test('get user id', async () => { + axiosMock.mockResolvedValue({ ...axiosResponse, data: userIdResponse }); + const res = await serviceNow.getUserID(); + const [url, { method }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl(`${USER_URL}${instance.username}`)); + expect(method).toEqual('get'); + expect(res).toEqual(userIdResponse.result[0].sys_id); + }); + + test('create incident', async () => { + axiosMock.mockResolvedValue({ ...axiosResponse, data: incidentAxiosResponse }); + const res = await serviceNow.createIncident(incident); + const [url, { method }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl(`${INCIDENT_URL}`)); + expect(method).toEqual('post'); + expect(res).toEqual(incidentResponse); + }); + + test('update incident', async () => { + axiosMock.mockResolvedValue({ ...axiosResponse, data: {} }); + const res = await serviceNow.updateIncident(params.incidentId!, { + short_description: params.title, + }); + const [url, { method }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl(`${INCIDENT_URL}/${params.incidentId}`)); + expect(method).toEqual('patch'); + expect(res).not.toBeDefined(); + }); + + test('add comment', async () => { + const fieldKey = 'comments'; + + axiosMock.mockResolvedValue({ ...axiosResponse, data: {} }); + const res = await serviceNow.addComment( + params.incidentId!, + params.comments![0].comment, + fieldKey + ); + + const [url, { method, data }] = axiosMock.mock.calls[0]; + + expect(url).toEqual(prependInstanceUrl(`${INCIDENT_URL}/${params.incidentId}`)); + expect(method).toEqual('patch'); + expect(data).toEqual({ [fieldKey]: params.comments![0].comment }); + expect(res).not.toBeDefined(); + }); + + test('add batch comment', async () => { + const fieldKey = 'comments'; + + axiosMock.mockResolvedValue({ ...axiosResponse, data: {} }); + const res = await serviceNow.batchAddComments( + params.incidentId!, + params.comments!.map(c => c.comment), + fieldKey + ); + + for (let i = 0; i < params.comments!.length; i++) { + const [url, { method, data }] = axiosMock.mock.calls[i]; + expect(url).toEqual(prependInstanceUrl(`${INCIDENT_URL}/${params.incidentId}`)); + expect(method).toEqual('patch'); + expect(data).toEqual({ [fieldKey]: params.comments![i].comment }); + expect(res).not.toBeDefined(); + } + }); + + test('throw if not status is not ok', async () => { + expect.assertions(1); + + axiosMock.mockResolvedValue({ ...axiosResponse, status: 401 }); + try { + await serviceNow.getUserID(); + } catch (error) { + expect(error.message).toEqual('[ServiceNow]: Instance is not alive.'); + } + }); + + test('throw if not content-type is not application/json', async () => { + expect.assertions(1); + + axiosMock.mockResolvedValue({ + ...axiosResponse, + headers: { 'content-type': 'application/html' }, + }); + try { + await serviceNow.getUserID(); + } catch (error) { + expect(error.message).toEqual('[ServiceNow]: Instance is not alive.'); + } + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/index.ts new file mode 100644 index 0000000000000..f62e7e6455a97 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/index.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import axios, { AxiosInstance, Method, AxiosResponse } from 'axios'; + +import { INCIDENT_URL, USER_URL, COMMENT_URL } from './constants'; +import { Instance, Incident, IncidentResponse, UpdateIncident, CommentResponse } from './types'; +import { CommentType } from '../../servicenow/types'; + +const validStatusCodes = [200, 201]; + +const commentTemplate = { + name: 'incident', + element: 'comments', +}; + +class ServiceNow { + private readonly incidentUrl: string; + private readonly commentUrl: string; + private readonly userUrl: string; + private readonly axios: AxiosInstance; + + constructor(private readonly instance: Instance) { + if ( + !this.instance || + !this.instance.url || + !this.instance.username || + !this.instance.password + ) { + throw Error('[Action][ServiceNow]: Wrong configuration.'); + } + + this.incidentUrl = `${this.instance.url}/${INCIDENT_URL}`; + this.commentUrl = `${this.instance.url}/${COMMENT_URL}`; + this.userUrl = `${this.instance.url}/${USER_URL}`; + this.axios = axios.create({ + auth: { username: this.instance.username, password: this.instance.password }, + }); + } + + private _throwIfNotAlive(status: number, contentType: string) { + if (!validStatusCodes.includes(status) || !contentType.includes('application/json')) { + throw new Error('[ServiceNow]: Instance is not alive.'); + } + } + + private async _request({ + url, + method = 'get', + data = {}, + }: { + url: string; + method?: Method; + data?: any; + }): Promise { + const res = await this.axios(url, { method, data }); + this._throwIfNotAlive(res.status, res.headers['content-type']); + return res; + } + + private _patch({ url, data }: { url: string; data: any }): Promise { + return this._request({ + url, + method: 'patch', + data, + }); + } + + async getUserID(): Promise { + const res = await this._request({ url: `${this.userUrl}${this.instance.username}` }); + return res.data.result[0].sys_id; + } + + async createIncident(incident: Incident): Promise { + const res = await this._request({ + url: `${this.incidentUrl}`, + method: 'post', + data: { ...incident }, + }); + + return { number: res.data.result.number, incidentId: res.data.result.sys_id }; + } + + async updateIncident(incidentId: string, incident: UpdateIncident): Promise { + const res = await this._patch({ + url: `${this.incidentUrl}/${incidentId}`, + data: { ...incident }, + }); + + return { number: res.data.result.number, incidentId: res.data.result.sys_id }; + } + + async batchCreateComments( + incidentId: string, + comments: CommentType[], + field: string + ): Promise { + const res = await Promise.all(comments.map(c => this.createComment(incidentId, c, field))); + return res; + } + + async batchUpdateComments(comments: CommentType[]): Promise { + const res = await Promise.all(comments.map(c => this.updateComment(c))); + return res; + } + + async createComment( + incidentId: string, + comment: CommentType, + field: string + ): Promise { + const res = await this._request({ + url: this.commentUrl, + method: 'post', + data: { ...commentTemplate, element_id: incidentId, value: comment.comment, element: field }, + }); + + return { commentId: res.data.result.sys_id }; + } + + async updateComment(comment: CommentType): Promise { + const res = await this._patch({ + url: `${this.commentUrl}/${comment.incidentCommentId}`, + data: { value: comment.comment }, + }); + + return { commentId: res.data.result.sys_id }; + } +} + +export { ServiceNow }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/types.ts new file mode 100644 index 0000000000000..2f7475fc9b484 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/servicenow/types.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Instance { + url: string; + username: string; + password: string; +} + +export interface Incident { + short_description?: string; + description?: string; + caller_id?: string; +} + +export interface IncidentResponse { + number: string; + incidentId: string; +} + +export interface CommentResponse { + commentId: string; +} + +export type UpdateIncident = Partial; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow.test.ts deleted file mode 100644 index 9ae96cb23a5c3..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -jest.mock('./lib/post_servicenow', () => ({ - postServiceNow: jest.fn(), -})); - -import { getActionType } from './servicenow'; -import { ActionType, Services, ActionTypeExecutorOptions } from '../types'; -import { validateConfig, validateSecrets, validateParams } from '../lib'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { postServiceNow } from './lib/post_servicenow'; -import { createActionTypeRegistry } from './index.test'; -import { configUtilsMock } from '../actions_config.mock'; - -const postServiceNowMock = postServiceNow as jest.Mock; - -const ACTION_TYPE_ID = '.servicenow'; - -const services: Services = { - callCluster: async (path: string, opts: any) => {}, - savedObjectsClient: savedObjectsClientMock.create(), -}; - -let actionType: ActionType; - -const mockServiceNow = { - config: { - apiUrl: 'www.servicenowisinkibanaactions.com', - }, - secrets: { - password: 'secret-password', - username: 'secret-username', - }, - params: { - comments: 'hello cool service now incident', - short_description: 'this is a cool service now incident', - }, -}; - -beforeAll(() => { - const { actionTypeRegistry } = createActionTypeRegistry(); - actionType = actionTypeRegistry.get(ACTION_TYPE_ID); -}); - -describe('get()', () => { - test('should return correct action type', () => { - expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('ServiceNow'); - }); -}); - -describe('validateConfig()', () => { - test('should validate and pass when config is valid', () => { - const { config } = mockServiceNow; - expect(validateConfig(actionType, config)).toEqual(config); - }); - - test('should validate and throw error when config is invalid', () => { - expect(() => { - validateConfig(actionType, { shouldNotBeHere: true }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]"` - ); - }); - - test('should validate and pass when the servicenow url is whitelisted', () => { - actionType = getActionType({ - configurationUtilities: { - ...configUtilsMock, - ensureWhitelistedUri: url => { - expect(url).toEqual('https://events.servicenow.com/v2/enqueue'); - }, - }, - }); - - expect( - validateConfig(actionType, { apiUrl: 'https://events.servicenow.com/v2/enqueue' }) - ).toEqual({ apiUrl: 'https://events.servicenow.com/v2/enqueue' }); - }); - - test('config validation returns an error if the specified URL isnt whitelisted', () => { - actionType = getActionType({ - configurationUtilities: { - ...configUtilsMock, - ensureWhitelistedUri: _ => { - throw new Error(`target url is not whitelisted`); - }, - }, - }); - - expect(() => { - validateConfig(actionType, { apiUrl: 'https://events.servicenow.com/v2/enqueue' }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type config: error configuring servicenow action: target url is not whitelisted"` - ); - }); -}); - -describe('validateSecrets()', () => { - test('should validate and pass when secrets is valid', () => { - const { secrets } = mockServiceNow; - expect(validateSecrets(actionType, secrets)).toEqual(secrets); - }); - - test('should validate and throw error when secrets is invalid', () => { - expect(() => { - validateSecrets(actionType, { username: false }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"` - ); - - expect(() => { - validateSecrets(actionType, { username: false, password: 'hello' }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [username]: expected value of type [string] but got [boolean]"` - ); - }); -}); - -describe('validateParams()', () => { - test('should validate and pass when params is valid', () => { - const { params } = mockServiceNow; - expect(validateParams(actionType, params)).toEqual(params); - }); - - test('should validate and throw error when params is invalid', () => { - expect(() => { - validateParams(actionType, { eventAction: 'ackynollage' }); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action params: [short_description]: expected value of type [string] but got [undefined]"` - ); - }); -}); - -describe('execute()', () => { - beforeEach(() => { - postServiceNowMock.mockReset(); - }); - const { config, params, secrets } = mockServiceNow; - test('should succeed with valid params', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 201, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - const { apiUrl, data, headers } = postServiceNowMock.mock.calls[0][0]; - expect({ apiUrl, data, headers, secrets }).toMatchInlineSnapshot(` - Object { - "apiUrl": "www.servicenowisinkibanaactions.com", - "data": Object { - "comments": "hello cool service now incident", - "short_description": "this is a cool service now incident", - }, - "headers": Object { - "Accept": "application/json", - "Content-Type": "application/json", - }, - "secrets": Object { - "password": "secret-password", - "username": "secret-username", - }, - } - `); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "data": "data-here", - "status": "ok", - } - `); - }); - - test('should fail when postServiceNow throws', async () => { - postServiceNowMock.mockImplementation(() => { - throw new Error('doing some testing'); - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event", - "serviceMessage": "doing some testing", - "status": "error", - } - `); - }); - - test('should fail when postServiceNow returns 429', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 429, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event: http status 429, retry later", - "retry": true, - "status": "error", - } - `); - }); - - test('should fail when postServiceNow returns 501', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 501, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event: http status 501, retry later", - "retry": true, - "status": "error", - } - `); - }); - - test('should fail when postServiceNow returns 418', async () => { - postServiceNowMock.mockImplementation(() => { - return { status: 418, data: 'data-here' }; - }); - - const actionId = 'some-action-id'; - const executorOptions: ActionTypeExecutorOptions = { - actionId, - config, - params, - secrets, - services, - }; - const actionResponse = await actionType.executor(executorOptions); - expect(actionResponse).toMatchInlineSnapshot(` - Object { - "actionId": "some-action-id", - "message": "error posting servicenow event: unexpected status 418", - "status": "error", - } - `); - }); -}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow.ts deleted file mode 100644 index 0ad435281eba4..0000000000000 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { curry } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { schema, TypeOf } from '@kbn/config-schema'; -import { - ActionType, - ActionTypeExecutorOptions, - ActionTypeExecutorResult, - ExecutorType, -} from '../types'; -import { ActionsConfigurationUtilities } from '../actions_config'; -import { postServiceNow } from './lib/post_servicenow'; - -// config definition -export type ConfigType = TypeOf; - -const ConfigSchemaProps = { - apiUrl: schema.string(), -}; - -const ConfigSchema = schema.object(ConfigSchemaProps); - -function validateConfig( - configurationUtilities: ActionsConfigurationUtilities, - configObject: ConfigType -) { - if (configObject.apiUrl == null) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiNullError', { - defaultMessage: 'ServiceNow [apiUrl] is required', - }); - } - try { - configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); - } catch (whitelistError) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiWhitelistError', { - defaultMessage: 'error configuring servicenow action: {message}', - values: { - message: whitelistError.message, - }, - }); - } -} -// secrets definition -export type SecretsType = TypeOf; -const SecretsSchemaProps = { - password: schema.string(), - username: schema.string(), -}; - -const SecretsSchema = schema.object(SecretsSchemaProps); - -function validateSecrets( - configurationUtilities: ActionsConfigurationUtilities, - secrets: SecretsType -) { - if (secrets.username == null) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiUserError', { - defaultMessage: 'error configuring servicenow action: no secrets [username] provided', - }); - } - if (secrets.password == null) { - return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiPasswordError', { - defaultMessage: 'error configuring servicenow action: no secrets [password] provided', - }); - } -} - -// params definition - -export type ParamsType = TypeOf; - -const ParamsSchema = schema.object({ - comments: schema.maybe(schema.string()), - short_description: schema.string(), -}); - -// action type definition -export function getActionType({ - configurationUtilities, - executor = serviceNowExecutor, -}: { - configurationUtilities: ActionsConfigurationUtilities; - executor?: ExecutorType; -}): ActionType { - return { - id: '.servicenow', - name: i18n.translate('xpack.actions.builtin.servicenowTitle', { - defaultMessage: 'ServiceNow', - }), - validate: { - config: schema.object(ConfigSchemaProps, { - validate: curry(validateConfig)(configurationUtilities), - }), - secrets: schema.object(SecretsSchemaProps, { - validate: curry(validateSecrets)(configurationUtilities), - }), - params: ParamsSchema, - }, - executor, - }; -} - -// action executor - -async function serviceNowExecutor( - execOptions: ActionTypeExecutorOptions -): Promise { - const actionId = execOptions.actionId; - const config = execOptions.config as ConfigType; - const secrets = execOptions.secrets as SecretsType; - const params = execOptions.params as ParamsType; - const headers = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - let response; - try { - response = await postServiceNow({ apiUrl: config.apiUrl, data: params, headers, secrets }); - } catch (err) { - const message = i18n.translate('xpack.actions.builtin.servicenow.postingErrorMessage', { - defaultMessage: 'error posting servicenow event', - }); - return { - status: 'error', - actionId, - message, - serviceMessage: err.message, - }; - } - if (response.status === 200 || response.status === 201 || response.status === 204) { - return { - status: 'ok', - actionId, - data: response.data, - }; - } - - if (response.status === 429 || response.status >= 500) { - const message = i18n.translate('xpack.actions.builtin.servicenow.postingRetryErrorMessage', { - defaultMessage: 'error posting servicenow event: http status {status}, retry later', - values: { - status: response.status, - }, - }); - - return { - status: 'error', - actionId, - message, - retry: true, - }; - } - - const message = i18n.translate('xpack.actions.builtin.servicenow.postingUnexpectedErrorMessage', { - defaultMessage: 'error posting servicenow event: unexpected status {status}', - values: { - status: response.status, - }, - }); - - return { - status: 'error', - actionId, - message, - }; -} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts new file mode 100644 index 0000000000000..ecdfa81f7139f --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { handleCreateIncident, handleUpdateIncident } from './action_handlers'; +import { ServiceNow } from '../lib/servicenow'; +import { finalMapping } from './mock'; +import { Incident } from '../lib/servicenow/types'; + +jest.mock('../lib/servicenow'); + +const ServiceNowMock = ServiceNow as jest.Mock; + +const incident: Incident = { + short_description: 'A title', + description: 'A description', +}; + +const comments = [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: '263ede42075300100e48fbbf7c1ed047', + }, + { + commentId: 'e3db587f-ca27-4ae9-ad2e-31f2dcc9bd0d', + version: 'WlK3LDFd', + comment: 'Another comment', + incidentCommentId: '315e1ece071300100e48fbbf7c1ed0d0', + }, +]; + +describe('handleCreateIncident', () => { + beforeAll(() => { + ServiceNowMock.mockImplementation(() => { + return { + serviceNow: { + getUserID: jest.fn().mockResolvedValue('1234'), + createIncident: jest.fn().mockResolvedValue({ id: '123', number: 'INC01' }), + updateIncident: jest.fn(), + batchAddComments: jest.fn(), + }, + }; + }); + }); + + test('create an incident without comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleCreateIncident({ + serviceNow, + params: incident, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.createIncident).toHaveBeenCalled(); + expect(serviceNow.createIncident).toHaveBeenCalledWith(incident); + expect(serviceNow.createIncident).toHaveReturned(); + expect(serviceNow.batchAddComments).not.toHaveBeenCalled(); + expect(res).toEqual({ id: '123', number: 'INC01' }); + }); + + test('create an incident with comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + const res = await handleCreateIncident({ + serviceNow, + params: incident, + comments, + mapping: finalMapping, + }); + + expect(serviceNow.createIncident).toHaveBeenCalled(); + expect(serviceNow.createIncident).toHaveBeenCalledWith(incident); + expect(serviceNow.createIncident).toHaveReturned(); + expect(serviceNow.batchAddComments).toHaveBeenCalled(); + expect(serviceNow.batchAddComments).toHaveBeenCalledWith( + '123', + comments.map(c => c.comment), + 'comments' + ); + expect(res).toEqual({ id: '123', number: 'INC01' }); + }); + + test('update an incident without comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params: incident, + comments: [], + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', incident); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchAddComments).not.toHaveBeenCalled(); + }); + + test('update an incident with comments', async () => { + const { serviceNow } = new ServiceNowMock(); + + await handleUpdateIncident({ + incidentId: '123', + serviceNow, + params: incident, + comments, + mapping: finalMapping, + }); + + expect(serviceNow.updateIncident).toHaveBeenCalled(); + expect(serviceNow.updateIncident).toHaveBeenCalledWith('123', incident); + expect(serviceNow.updateIncident).toHaveReturned(); + expect(serviceNow.batchAddComments).toHaveBeenCalledWith( + '123', + comments.map(c => c.comment), + 'comments' + ); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts new file mode 100644 index 0000000000000..ddb168432a0ff --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/action_handlers.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { zipWith } from 'lodash'; +import { Incident, CommentResponse } from '../lib/servicenow/types'; +import { + ActionHandlerArguments, + UpdateParamsType, + UpdateActionHandlerArguments, + IncidentCreationResponse, + CommentType, + CommentsZipped, +} from './types'; + +export const handleCreateIncident = async ({ + serviceNow, + params, + comments, + mapping, +}: ActionHandlerArguments): Promise => { + const paramsAsIncident = params as Incident; + + const { incidentId, number } = await serviceNow.createIncident({ + ...paramsAsIncident, + }); + + const res: IncidentCreationResponse = { incidentId, number }; + + // Should return comment ID + if (comments && Array.isArray(comments) && comments.length > 0) { + const commentResponse = await serviceNow.batchCreateComments( + incidentId, + comments, + mapping.get('comments').target + ); + + res.comments = zipWith(comments, commentResponse, (a: CommentType, b: CommentResponse) => ({ + commentId: a.commentId, + incidentCommentId: b.commentId, + })); + } + + return { ...res }; +}; + +export const handleUpdateIncident = async ({ + incidentId, + serviceNow, + params, + comments, + mapping, +}: UpdateActionHandlerArguments) => { + const paramsAsIncident = params as UpdateParamsType; + + const { number } = await serviceNow.updateIncident(incidentId, { + ...paramsAsIncident, + }); + + const res: IncidentCreationResponse = { incidentId, number }; + + // Should return comment ID + if (comments && Array.isArray(comments) && comments.length > 0) { + const commentsToUpdate = comments.filter(c => c.incidentCommentId); + const commentsToCreate = comments.filter(c => !c.incidentCommentId); + + const commentCreationResponse = await serviceNow.batchCreateComments( + incidentId, + commentsToCreate, + mapping.get('comments').target + ); + + const commentUpdateResponse = await serviceNow.batchUpdateComments(commentsToUpdate); + + const updateRes: CommentsZipped[] = zipWith( + commentsToCreate, + commentCreationResponse, + (a: CommentType, b: CommentResponse) => ({ + commentId: a.commentId, + incidentCommentId: b.commentId, + }) + ); + + const createRes: CommentsZipped[] = zipWith( + commentsToUpdate, + commentUpdateResponse, + (a: CommentType, b: CommentResponse) => ({ + commentId: a.commentId, + incidentCommentId: b.commentId, + }) + ); + + res.comments = [...updateRes, ...createRes]; + } + + return { ...res }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts new file mode 100644 index 0000000000000..a0ffd859e14ca --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ACTION_TYPE_ID = '.servicenow'; +export const SUPPORTED_SOURCE_FIELDS = ['title', 'comments', 'description']; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts new file mode 100644 index 0000000000000..efe626a416b20 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { normalizeMapping, buildMap, mapParams } from './helpers'; +import { mapping, finalMapping } from './mock'; +import { SUPPORTED_SOURCE_FIELDS } from './constants'; +import { MapsType } from './types'; + +const maliciousMapping: MapsType[] = [ + { source: '__proto__', target: 'short_description', onEditAndUpdate: 'nothing' }, + { source: 'description', target: '__proto__', onEditAndUpdate: 'nothing' }, + { source: 'comments', target: 'comments', onEditAndUpdate: 'nothing' }, + { source: 'unsupportedSource', target: 'comments', onEditAndUpdate: 'nothing' }, +]; + +describe('sanitizeMapping', () => { + test('remove malicious fields', () => { + const sanitizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); + expect(sanitizedMapping.every(m => m.source !== '__proto__' && m.target !== '__proto__')).toBe( + true + ); + }); + + test('remove unsuppported source fields', () => { + const normalizedMapping = normalizeMapping(SUPPORTED_SOURCE_FIELDS, maliciousMapping); + expect(normalizedMapping).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: 'unsupportedSource', + target: 'comments', + onEditAndUpdate: 'nothing', + }), + ]) + ); + }); +}); + +describe('buildMap', () => { + test('builds sanitized Map', () => { + const finalMap = buildMap(maliciousMapping); + expect(finalMap.get('__proto__')).not.toBeDefined(); + }); + + test('builds Map correct', () => { + const final = buildMap(mapping); + expect(final).toEqual(finalMapping); + }); +}); + +describe('mapParams', () => { + test('maps params correctly', () => { + const params = { + caseId: '123', + incidentId: '456', + title: 'Incident title', + description: 'Incident description', + }; + + const fields = mapParams(params, finalMapping); + + expect(fields).toEqual({ + short_description: 'Incident title', + description: 'Incident description', + }); + }); + + test('do not add fields not in mapping', () => { + const params = { + caseId: '123', + incidentId: '456', + title: 'Incident title', + description: 'Incident description', + }; + const fields = mapParams(params, finalMapping); + + const { title, description, ...unexpectedFields } = params; + + expect(fields).not.toEqual(expect.objectContaining(unexpectedFields)); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts new file mode 100644 index 0000000000000..605743856f668 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/helpers.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SUPPORTED_SOURCE_FIELDS } from './constants'; +import { MapsType, FinalMapping } from './types'; + +export const normalizeMapping = (fields: string[], mapping: MapsType[]): MapsType[] => { + // Prevent prototype pollution and remove unsupported fields + return mapping.filter( + m => m.source !== '__proto__' && m.target !== '__proto__' && fields.includes(m.source) + ); +}; + +export const buildMap = (mapping: MapsType[]): FinalMapping => { + return normalizeMapping(SUPPORTED_SOURCE_FIELDS, mapping).reduce((fieldsMap, field) => { + const { source, target, onEditAndUpdate } = field; + fieldsMap.set(source, { target, onEditAndUpdate }); + fieldsMap.set(target, { target: source, onEditAndUpdate }); + return fieldsMap; + }, new Map()); +}; + +interface KeyAny { + [key: string]: unknown; +} + +export const mapParams = (params: any, mapping: FinalMapping) => { + return Object.keys(params).reduce((prev: KeyAny, curr: string): KeyAny => { + const field = mapping.get(curr); + if (field) { + prev[field.target] = params[curr]; + } + return prev; + }, {}); +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts new file mode 100644 index 0000000000000..8a29fa2731007 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -0,0 +1,257 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getActionType } from '.'; +import { ActionType, Services, ActionTypeExecutorOptions } from '../../types'; +import { validateConfig, validateSecrets, validateParams } from '../../lib'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { createActionTypeRegistry } from '../index.test'; +import { configUtilsMock } from '../../actions_config.mock'; + +import { ACTION_TYPE_ID } from './constants'; +import * as i18n from './translations'; + +import { handleCreateIncident, handleUpdateIncident } from './action_handlers'; +import { incidentResponse } from './mock'; + +jest.mock('./action_handlers'); + +const handleCreateIncidentMock = handleCreateIncident as jest.Mock; +const handleUpdateIncidentMock = handleUpdateIncident as jest.Mock; + +const services: Services = { + callCluster: async (path: string, opts: any) => {}, + savedObjectsClient: savedObjectsClientMock.create(), +}; + +let actionType: ActionType; + +const mockOptions = { + name: 'servicenow-connector', + actionTypeId: '.servicenow', + secrets: { + username: 'secret-username', + password: 'secret-password', + }, + config: { + apiUrl: 'https://service-now.com', + casesConfiguration: { + closure: 'manual', + mapping: [ + { + source: 'title', + target: 'short_description', + onEditAndUpdate: 'overwrite', + }, + { + source: 'description', + target: 'description', + onEditAndUpdate: 'overwrite', + }, + { + source: 'comments', + target: 'work_notes', + onEditAndUpdate: 'append', + }, + ], + }, + }, + params: { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + incidentId: 'ceb5986e079f00100e48fbbf7c1ed06d', + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: '315e1ece071300100e48fbbf7c1ed0d0', + }, + ], + }, +}; + +beforeAll(() => { + const { actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); +}); + +describe('get()', () => { + test('should return correct action type', () => { + expect(actionType.id).toEqual(ACTION_TYPE_ID); + expect(actionType.name).toEqual(i18n.NAME); + }); +}); + +describe('validateConfig()', () => { + test('should validate and pass when config is valid', () => { + const { config } = mockOptions; + expect(validateConfig(actionType, config)).toEqual(config); + }); + + test('should validate and throw error when config is invalid', () => { + expect(() => { + validateConfig(actionType, { shouldNotBeHere: true }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]"` + ); + }); + + test('should validate and pass when the servicenow url is whitelisted', () => { + actionType = getActionType({ + configurationUtilities: { + ...configUtilsMock, + ensureWhitelistedUri: url => { + expect(url).toEqual(mockOptions.config.apiUrl); + }, + }, + }); + + expect(validateConfig(actionType, mockOptions.config)).toEqual(mockOptions.config); + }); + + test('config validation returns an error if the specified URL isnt whitelisted', () => { + actionType = getActionType({ + configurationUtilities: { + ...configUtilsMock, + ensureWhitelistedUri: _ => { + throw new Error(`target url is not whitelisted`); + }, + }, + }); + + expect(() => { + validateConfig(actionType, mockOptions.config); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: error configuring servicenow action: target url is not whitelisted"` + ); + }); +}); + +describe('validateSecrets()', () => { + test('should validate and pass when secrets is valid', () => { + const { secrets } = mockOptions; + expect(validateSecrets(actionType, secrets)).toEqual(secrets); + }); + + test('should validate and throw error when secrets is invalid', () => { + expect(() => { + validateSecrets(actionType, { username: false }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"` + ); + + expect(() => { + validateSecrets(actionType, { username: false, password: 'hello' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [username]: expected value of type [string] but got [boolean]"` + ); + }); +}); + +describe('validateParams()', () => { + test('should validate and pass when params is valid', () => { + const { params } = mockOptions; + expect(validateParams(actionType, params)).toEqual(params); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateParams(actionType, {}); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [caseId]: expected value of type [string] but got [undefined]"` + ); + }); +}); + +describe('execute()', () => { + beforeEach(() => { + handleCreateIncidentMock.mockReset(); + handleUpdateIncidentMock.mockReset(); + }); + + test('should create an incident', async () => { + const actionId = 'some-id'; + const { incidentId, ...rest } = mockOptions.params; + + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...rest }, + secrets: mockOptions.secrets, + services, + }; + + handleCreateIncidentMock.mockImplementation(() => incidentResponse); + + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toEqual({ actionId, status: 'ok', data: incidentResponse }); + }); + + test('should throw an error when failed to create incident', async () => { + expect.assertions(1); + const { incidentId, ...rest } = mockOptions.params; + + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...rest }, + secrets: mockOptions.secrets, + services, + }; + const errorMessage = 'Failed to create incident'; + + handleCreateIncidentMock.mockImplementation(() => { + throw new Error(errorMessage); + }); + + try { + await actionType.executor(executorOptions); + } catch (error) { + expect(error.message).toEqual(errorMessage); + } + }); + + test('should update an incident', async () => { + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...mockOptions.params, executorAction: 'updateIncident' }, + secrets: mockOptions.secrets, + services, + }; + + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toEqual({ actionId, status: 'ok' }); + }); + + test('should throw an error when failed to update an incident', async () => { + expect.assertions(1); + + const actionId = 'some-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config: mockOptions.config, + params: { ...mockOptions.params, executorAction: 'updateIncident' }, + secrets: mockOptions.secrets, + services, + }; + const errorMessage = 'Failed to update incident'; + + handleUpdateIncidentMock.mockImplementation(() => { + throw new Error(errorMessage); + }); + + try { + await actionType.executor(executorOptions); + } catch (error) { + expect(error.message).toEqual(errorMessage); + } + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts new file mode 100644 index 0000000000000..a0bd327792cc0 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { curry } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { + ActionType, + ActionTypeExecutorOptions, + ActionTypeExecutorResult, + ExecutorType, +} from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ServiceNow } from '../lib/servicenow'; + +import * as i18n from './translations'; + +import { ACTION_TYPE_ID } from './constants'; +import { ConfigType, SecretsType, ParamsType, CommentType } from './types'; + +import { ConfigSchemaProps, SecretsSchemaProps, ParamsSchema } from './schema'; + +import { buildMap, mapParams } from './helpers'; +import { handleCreateIncident, handleUpdateIncident } from './action_handlers'; + +function validateConfig( + configurationUtilities: ActionsConfigurationUtilities, + configObject: ConfigType +) { + try { + configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); + } catch (whitelistError) { + return i18n.WHITE_LISTED_ERROR(whitelistError.message); + } +} + +function validateSecrets( + configurationUtilities: ActionsConfigurationUtilities, + secrets: SecretsType +) { + if (secrets.username == null) { + return i18n.NO_USERNAME; + } + if (secrets.password == null) { + return i18n.NO_PASSWORD; + } +} + +// action type definition +export function getActionType({ + configurationUtilities, + executor = serviceNowExecutor, +}: { + configurationUtilities: ActionsConfigurationUtilities; + executor?: ExecutorType; +}): ActionType { + return { + id: ACTION_TYPE_ID, + name: i18n.NAME, + validate: { + config: schema.object(ConfigSchemaProps, { + validate: curry(validateConfig)(configurationUtilities), + }), + secrets: schema.object(SecretsSchemaProps, { + validate: curry(validateSecrets)(configurationUtilities), + }), + params: ParamsSchema, + }, + executor, + }; +} + +// action executor + +async function serviceNowExecutor( + execOptions: ActionTypeExecutorOptions +): Promise { + const actionId = execOptions.actionId; + const { + apiUrl, + casesConfiguration: { mapping }, + } = execOptions.config as ConfigType; + const { username, password } = execOptions.secrets as SecretsType; + const params = execOptions.params as ParamsType; + const { comments, incidentId, ...restParams } = params; + + const finalMap = buildMap(mapping); + const restParamsMapped = mapParams(restParams, finalMap); + const serviceNow = new ServiceNow({ url: apiUrl, username, password }); + + const handlerInput = { + serviceNow, + params: restParamsMapped, + comments: comments as CommentType[], + mapping: finalMap, + }; + + const res: Pick & + Pick = { + status: 'ok', + actionId, + }; + + let data = {}; + + if (!incidentId) { + data = await handleCreateIncident(handlerInput); + + return { + ...res, + data, + }; + } else { + data = await handleUpdateIncident({ incidentId, ...handlerInput }); + return { + ...res, + data, + }; + } +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts new file mode 100644 index 0000000000000..7f59506d87d2e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mock.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MapsType, FinalMapping, ParamsType } from './types'; +import { Incident } from '../lib/servicenow/types'; + +const mapping: MapsType[] = [ + { source: 'title', target: 'short_description', onEditAndUpdate: 'nothing' }, + { source: 'description', target: 'description', onEditAndUpdate: 'nothing' }, + { source: 'comments', target: 'comments', onEditAndUpdate: 'nothing' }, +]; + +const finalMapping: FinalMapping = new Map(); + +finalMapping.set('title', { + target: 'short_description', + onEditAndUpdate: 'nothing', +}); + +finalMapping.set('description', { + target: 'description', + onEditAndUpdate: 'nothing', +}); + +finalMapping.set('comments', { + target: 'comments', + onEditAndUpdate: 'nothing', +}); + +finalMapping.set('short_description', { + target: 'title', + onEditAndUpdate: 'nothing', +}); + +const params: ParamsType = { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + incidentId: 'ceb5986e079f00100e48fbbf7c1ed06d', + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + version: 'WzU3LDFd', + comment: 'A comment', + incidentCommentId: '263ede42075300100e48fbbf7c1ed047', + }, + { + commentId: 'e3db587f-ca27-4ae9-ad2e-31f2dcc9bd0d', + version: 'WlK3LDFd', + comment: 'Another comment', + incidentCommentId: '315e1ece071300100e48fbbf7c1ed0d0', + }, + ], +}; + +const incidentResponse = { + id: 'c816f79cc0a8016401c5a33be04be441', + number: 'INC0010001', +}; + +const userId = '2e9a0a5e2f79001016ab51172799b670'; + +const axiosResponse = { + status: 200, + headers: { + 'content-type': 'application/json', + }, +}; +const userIdResponse = { + result: [{ sys_id: userId }], +}; + +const incidentAxiosResponse = { + result: { sys_id: incidentResponse.id, number: incidentResponse.number }, +}; + +const instance = { + url: 'https://instance.service-now.com', + username: 'username', + password: 'password', +}; + +const incident: Incident = { + short_description: params.title, + description: params.description, + caller_id: userId, +}; + +export { + mapping, + finalMapping, + params, + incidentResponse, + incidentAxiosResponse, + userId, + userIdResponse, + axiosResponse, + instance, + incident, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts new file mode 100644 index 0000000000000..eb939d815cc47 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +export const MapsSchema = schema.object({ + source: schema.string(), + target: schema.string(), + onEditAndUpdate: schema.oneOf([ + schema.literal('nothing'), + schema.literal('overwrite'), + schema.literal('append'), + ]), +}); + +export const CasesConfigurationSchema = schema.object({ + closure: schema.oneOf([ + schema.literal('manual'), + schema.literal('newIncident'), + schema.literal('closedIncident'), + ]), + mapping: schema.arrayOf(MapsSchema), +}); + +export const ConfigSchemaProps = { + apiUrl: schema.string(), + casesConfiguration: CasesConfigurationSchema, +}; + +export const ConfigSchema = schema.object(ConfigSchemaProps); + +export const SecretsSchemaProps = { + password: schema.string(), + username: schema.string(), +}; + +export const SecretsSchema = schema.object(SecretsSchemaProps); + +export const CommentSchema = schema.object({ + commentId: schema.string(), + comment: schema.string(), + version: schema.maybe(schema.string()), + incidentCommentId: schema.maybe(schema.string()), +}); + +export const ExecutorAction = schema.oneOf([ + schema.literal('newIncident'), + schema.literal('updateIncident'), +]); + +export const ParamsSchema = schema.object({ + caseId: schema.string(), + comments: schema.maybe(schema.arrayOf(CommentSchema)), + description: schema.maybe(schema.string()), + title: schema.maybe(schema.string()), + incidentId: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts new file mode 100644 index 0000000000000..1ad9ab17344a6 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const API_URL_REQUIRED = i18n.translate( + 'xpack.actions.builtin.servicenow.servicenowApiNullError', + { + defaultMessage: 'ServiceNow [apiUrl] is required', + } +); + +export const WHITE_LISTED_ERROR = (message: string) => + i18n.translate('xpack.actions.builtin.servicenow.servicenowApiWhitelistError', { + defaultMessage: 'error configuring servicenow action: {message}', + values: { + message, + }, + }); + +export const NO_USERNAME = i18n.translate( + 'xpack.actions.builtin.servicenow.servicenowApiUserError', + { + defaultMessage: 'error configuring servicenow action: no secrets [username] provided', + } +); + +export const NO_PASSWORD = i18n.translate( + 'xpack.actions.builtin.servicenow.servicenowApiPasswordError', + { + defaultMessage: 'error configuring servicenow action: no secrets [password] provided', + } +); + +export const NAME = i18n.translate('xpack.actions.builtin.servicenowTitle', { + defaultMessage: 'ServiceNow', +}); + +export const ERROR_POSTING = i18n.translate( + 'xpack.actions.builtin.servicenow.postingErrorMessage', + { + defaultMessage: 'error posting servicenow event', + } +); + +export const RETRY_POSTING = (status: number) => + i18n.translate('xpack.actions.builtin.servicenow.postingRetryErrorMessage', { + defaultMessage: 'error posting servicenow event: http status {status}, retry later', + values: { + status, + }, + }); + +export const UNEXPECTED_STATUS = (status: number) => + i18n.translate('xpack.actions.builtin.servicenow.postingUnexpectedErrorMessage', { + defaultMessage: 'error posting servicenow event: unexpected status {status}', + values: { + status, + }, + }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts new file mode 100644 index 0000000000000..6bf8a53812a8d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; + +import { + ConfigSchema, + SecretsSchema, + ParamsSchema, + CasesConfigurationSchema, + MapsSchema, + CommentSchema, +} from './schema'; + +import { ServiceNow } from '../lib/servicenow'; + +// config definition +export type ConfigType = TypeOf; + +// secrets definition +export type SecretsType = TypeOf; + +export type ParamsType = TypeOf & { + [key: string]: any; +}; + +export type CasesConfigurationType = TypeOf; +export type MapsType = TypeOf; +export type CommentType = TypeOf; + +export type FinalMapping = Map; + +export interface ActionHandlerArguments { + serviceNow: ServiceNow; + params: any; + comments: CommentType[]; + mapping: FinalMapping; +} + +export type UpdateParamsType = Partial; +export type UpdateActionHandlerArguments = ActionHandlerArguments & { + incidentId: string; +}; + +export interface IncidentCreationResponse { + incidentId: string; + number: string; + comments?: CommentsZipped[]; +} + +export interface CommentsZipped { + commentId: string; + incidentCommentId: string; +}