diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/constants.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/constants.ts new file mode 100644 index 000000000000000..1f2bc7f5e8e5314 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/constants.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 const SUPPORTED_SOURCE_FIELDS = ['title', 'comments', 'description']; diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/index.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/index.ts new file mode 100644 index 000000000000000..9ecf39b8922e426 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/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 { connector as getServiceNowConnector } from './servicenow'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/schema.ts new file mode 100644 index 000000000000000..0527f40e207f840 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/schema.ts @@ -0,0 +1,81 @@ +/* + * 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 MappingActionType = schema.oneOf([ + schema.literal('nothing'), + schema.literal('overwrite'), + schema.literal('append'), +]); + +export const MapRecordSchema = schema.object({ + source: schema.string(), + target: schema.string(), + actionType: MappingActionType, +}); + +export const CaseConfigurationSchema = schema.object({ + mapping: schema.arrayOf(MapRecordSchema), +}); + +export const ConnectorPublicConfiguration = { + apiUrl: schema.string(), + casesConfiguration: CaseConfigurationSchema, +}; + +export const ConnectorPublicConfigurationSchema = schema.object(ConnectorPublicConfiguration); + +export const ConnectorSecretConfiguration = { + password: schema.string(), + username: schema.string(), +}; + +export const ConnectorSecretConfigurationSchema = schema.object(ConnectorSecretConfiguration); + +export const UserSchema = schema.object({ + fullName: schema.oneOf([schema.nullable(schema.string()), schema.maybe(schema.string())]), + username: schema.oneOf([schema.nullable(schema.string()), schema.maybe(schema.string())]), + email: schema.oneOf([schema.nullable(schema.string()), schema.maybe(schema.string())]), +}); + +const EntityInformation = { + createdAt: schema.string(), + createdBy: UserSchema, + updatedAt: schema.nullable(schema.string()), + updatedBy: schema.nullable(UserSchema), +}; + +export const EntityInformationSchema = schema.object(EntityInformation); + +export const CommentSchema = schema.object({ + commentId: schema.string(), + comment: schema.string(), + version: schema.maybe(schema.string()), + ...EntityInformation, +}); + +export const ExecutorActionSchema = schema.oneOf([ + schema.literal('getIncident'), + schema.literal('pushToService'), + schema.literal('handshake'), +]); + +export const ExecutorActionParams = { + caseId: schema.string(), + title: schema.string(), + description: schema.maybe(schema.string()), + comments: schema.maybe(schema.arrayOf(CommentSchema)), + externalCaseId: schema.nullable(schema.string()), + ...EntityInformation, +}; + +export const ExecutorActionParamsSchema = schema.object(ExecutorActionParams); + +export const ExecutorParamsSchema = schema.object({ + action: ExecutorActionSchema, + actionParams: ExecutorActionParamsSchema, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/api.ts new file mode 100644 index 000000000000000..3e82609ddf5bfae --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/api.ts @@ -0,0 +1,100 @@ +/* + * 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 { + ConnectorApi, + ExternalServiceParams, + ExternalServiceCommentResponse, + Comment, + PushToServiceResponse, + ConnectorApiHandlerArgs, +} from '../types'; +import { prepareFieldsForTransformation, transformFields, transformComments } from '../utils'; + +const handshakeHandler = async ({ + externalService, + mapping, + params, +}: ConnectorApiHandlerArgs) => {}; +const getIncidentHandler = async ({ + externalService, + mapping, + params, +}: ConnectorApiHandlerArgs) => {}; + +const pushToServiceHandler = async ({ + externalService, + mapping, + params, +}: ConnectorApiHandlerArgs): Promise => { + const { externalCaseId, comments } = params; + const updateIncident = externalCaseId ? true : false; + const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated']; + let currentIncident: ExternalServiceParams | undefined; + let res: PushToServiceResponse; + + if (externalCaseId) { + currentIncident = await externalService.getIncident(externalCaseId); + } + + const fields = prepareFieldsForTransformation({ + params, + mapping, + defaultPipes, + }); + + const incident = transformFields({ + params, + fields, + currentIncident, + }); + + if (updateIncident) { + res = await externalService.updateIncident({ incidentId: externalCaseId, incident }); + } else { + res = await externalService.createIncident({ incident }); + } + + if ( + comments && + Array.isArray(comments) && + comments.length > 0 && + mapping.get('comments').actionType !== 'nothing' + ) { + const commentsTransformed = transformComments(comments, ['informationAdded']); + + const createdComments = await Promise.all( + commentsTransformed.map(comment => + externalService.createComment({ + incidentId: res.id, + comment, + field: mapping.get('comments').target, + }) + ) + ); + + const zippedComments: ExternalServiceCommentResponse[] = zipWith( + commentsTransformed, + createdComments, + (a: Comment, b: ExternalServiceCommentResponse) => ({ + commentId: a.commentId, + pushedDate: b.pushedDate, + }) + ); + + res.comments = [...zippedComments]; + } + + return res; +}; + +export const api: ConnectorApi = { + handshake: handshakeHandler, + pushToService: pushToServiceHandler, + getIncident: getIncidentHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/config.ts new file mode 100644 index 000000000000000..3f303f00be1d05f --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/config.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ConnectorConfiguration } from '../types'; +import * as i18n from './translations'; + +export const config: ConnectorConfiguration = { + id: '.servicenow', + name: i18n.NAME, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/index.ts new file mode 100644 index 000000000000000..3b4704a77afb1fc --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { createConnector } from '../utils'; + +import { api } from './api'; +import { config } from './config'; +import { validate } from './validators'; +import { createExternalService } from './service'; + +export const connector = createConnector({ + api, + config, + validate, + createExternalService, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/service.ts new file mode 100644 index 000000000000000..931aff58bdce5e5 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/service.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 axios from 'axios'; + +import { ExternalServiceCredential, ExternalService, ExternalServiceParams } from '../types'; +import { addTimeZoneToDate, patch, request } from '../utils'; + +const API_VERSION = 'v2'; +const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; +const USER_URL = `api/now/${API_VERSION}/table/sys_user?user_name=`; +const COMMENT_URL = `api/now/${API_VERSION}/table/incident`; + +// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html +const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`; + +const getErrorMessage = (msg: string) => { + return `[Action][ServiceNow]: ${msg}`; +}; + +export const createExternalService = ({ + url, + username, + password, +}: ExternalServiceCredential): ExternalService => { + if (!url || !username || !password) { + throw Error('[Action][ServiceNow]: Wrong configuration.'); + } + + const incidentUrl = `${url}/${INCIDENT_URL}`; + const commentUrl = `${url}/${COMMENT_URL}`; + const axiosInstance = axios.create({ + auth: { username, password }, + }); + + const getIncidentViewURL = (id: string) => { + return `${url}/${VIEW_INCIDENT_URL}${id}`; + }; + + const getIncident = async (id: string) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}/${id}`, + }); + + return { ...res.data.result }; + } catch (error) { + throw new Error( + getErrorMessage(`Unable to get incident with id ${id}. Error: ${error.message}`) + ); + } + }; + + const createIncident = async ({ incident }: ExternalServiceParams) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}`, + method: 'post', + data: { ...incident }, + }); + + return { + title: res.data.result.number, + id: res.data.result.sys_id, + pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(), + url: getIncidentViewURL(res.data.result.sys_id), + }; + } catch (error) { + throw new Error(getErrorMessage(`Unable to create incident. Error: ${error.message}`)); + } + }; + + const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + try { + const res = await patch({ + axios: axiosInstance, + url: `${incidentUrl}/${incidentId}`, + data: { ...incident }, + }); + + return { + title: res.data.result.number, + id: res.data.result.sys_id, + pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + url: getIncidentViewURL(res.data.result.sys_id), + }; + } catch (error) { + throw new Error( + getErrorMessage(`Unable to update incident with id ${incidentId}. Error: ${error.message}`) + ); + } + }; + + const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => { + try { + const res = await patch({ + axios: axiosInstance, + url: `${commentUrl}/${incidentId}`, + data: { [field]: comment.comment }, + }); + + return { + commentId: comment.commentId, + pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + return { + getIncident, + createIncident, + updateIncident, + createComment, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/translations.ts new file mode 100644 index 000000000000000..349b2e2988d6239 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/translations.ts @@ -0,0 +1,23 @@ +/* + * 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 NAME = i18n.translate('xpack.actions.builtin.servicenowTitle', { + defaultMessage: 'ServiceNow', +}); + +export const MAPPING_EMPTY = i18n.translate('xpack.actions.builtin.servicenow.emptyMapping', { + defaultMessage: '[casesConfiguration.mapping]: expected non-empty but got empty', +}); + +export const WHITE_LISTED_ERROR = (message: string) => + i18n.translate('xpack.actions.builtin.servicenow.servicenowApiWhitelistError', { + defaultMessage: 'error configuring servicenow action: {message}', + values: { + message, + }, + }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/types.ts new file mode 100644 index 000000000000000..41bc2aa2588073d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/types.ts @@ -0,0 +1,5 @@ +/* + * 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. + */ diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/validators.ts new file mode 100644 index 000000000000000..9773ec3d61a1744 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/servicenow/validators.ts @@ -0,0 +1,41 @@ +/* + * 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'; + +import { ActionsConfigurationUtilities } from '../../../actions_config'; +import { + ConnectorSecretConfigurationType, + ConnectorPublicConfigurationType, + ConnectorValidation, +} from '../types'; + +import * as i18n from './translations'; + +const validateConfig = ( + configurationUtilities: ActionsConfigurationUtilities, + configObject: ConnectorPublicConfigurationType +) => { + try { + if (isEmpty(configObject.casesConfiguration.mapping)) { + return i18n.MAPPING_EMPTY; + } + + configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); + } catch (whitelistError) { + return i18n.WHITE_LISTED_ERROR(whitelistError.message); + } +}; + +const validateSecrets = ( + configurationUtilities: ActionsConfigurationUtilities, + secrets: ConnectorSecretConfigurationType +) => {}; + +export const validate: ConnectorValidation = { + config: validateConfig, + secrets: validateSecrets, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/transformers.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/transformers.ts new file mode 100644 index 000000000000000..dc0a03fab8c7159 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/transformers.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TransformerArgs } from './types'; +import * as i18n from './translations'; + +export const informationCreated = ({ + value, + date, + user, + ...rest +}: TransformerArgs): TransformerArgs => ({ + value: `${value} ${i18n.FIELD_INFORMATION('create', date, user)}`, + ...rest, +}); + +export const informationUpdated = ({ + value, + date, + user, + ...rest +}: TransformerArgs): TransformerArgs => ({ + value: `${value} ${i18n.FIELD_INFORMATION('update', date, user)}`, + ...rest, +}); + +export const informationAdded = ({ + value, + date, + user, + ...rest +}: TransformerArgs): TransformerArgs => ({ + value: `${value} ${i18n.FIELD_INFORMATION('add', date, user)}`, + ...rest, +}); + +export const append = ({ value, previousValue, ...rest }: TransformerArgs): TransformerArgs => ({ + value: previousValue ? `${previousValue} \r\n${value}` : `${value}`, + ...rest, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/translations.ts new file mode 100644 index 000000000000000..3ee2e88dbd797aa --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/translations.ts @@ -0,0 +1,66 @@ +/* + * 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 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, + }, + }); + +export const FIELD_INFORMATION = ( + mode: string, + date: string | undefined, + user: string | undefined +) => { + switch (mode) { + case 'create': + return i18n.translate('xpack.actions.builtin.servicenow.informationCreated', { + values: { date, user }, + defaultMessage: '(created at {date} by {user})', + }); + case 'update': + return i18n.translate('xpack.actions.builtin.servicenow.informationUpdated', { + values: { date, user }, + defaultMessage: '(updated at {date} by {user})', + }); + case 'add': + return i18n.translate('xpack.actions.builtin.servicenow.informationAdded', { + values: { date, user }, + defaultMessage: '(added at {date} by {user})', + }); + default: + return i18n.translate('xpack.actions.builtin.servicenow.informationDefault', { + values: { date, user }, + defaultMessage: '(created at {date} by {user})', + }); + } +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/types.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/types.ts new file mode 100644 index 000000000000000..57ef97cb869a3f0 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/types.ts @@ -0,0 +1,139 @@ +/* + * 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 { + ConnectorPublicConfigurationSchema, + ConnectorSecretConfigurationSchema, + ExecutorParamsSchema, + CaseConfigurationSchema, + MapRecordSchema, + CommentSchema, + ExecutorActionParamsSchema, +} from './schema'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ExecutorType } from '../../types'; + +export interface AnyParams { + [index: string]: string; +} + +export type ConnectorPublicConfigurationType = TypeOf; +export type ConnectorSecretConfigurationType = TypeOf; + +export type ExecutorParams = TypeOf; +export type ExecutorActionParams = TypeOf; + +export type CaseConfiguration = TypeOf; +export type MapRecord = TypeOf; +export type Comment = TypeOf; + +export interface ApiParams extends ExecutorActionParams { + externalCase: Record; +} + +export interface ConnectorConfiguration { + id: string; + name: string; +} + +export interface ExternalServiceCredential { + url: string; + username: string; + password: string; +} + +export interface ConnectorValidation { + config: ( + configurationUtilities: ActionsConfigurationUtilities, + configObject: ConnectorPublicConfigurationType + ) => void; + secrets: ( + configurationUtilities: ActionsConfigurationUtilities, + secrets: ConnectorSecretConfigurationType + ) => void; +} + +export interface ExternalServiceCaseResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} + +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; +} + +export interface ExternalServiceParams { + [index: string]: any; +} + +export interface ExternalService { + getIncident: (id: string) => Promise; + createIncident: (params: ExternalServiceParams) => Promise; + updateIncident: (params: ExternalServiceParams) => Promise; + createComment: (params: ExternalServiceParams) => Promise; +} + +export interface ConnectorApiHandlerArgs { + externalService: ExternalService; + mapping: Map; + params: ApiParams; +} + +export interface PushToServiceResponse extends ExternalServiceCaseResponse { + comments?: ExternalServiceCommentResponse[]; +} + +export interface ConnectorApi { + handshake: (args: ConnectorApiHandlerArgs) => Promise; + pushToService: (args: ConnectorApiHandlerArgs) => Promise; + getIncident: (args: ConnectorApiHandlerArgs) => Promise; +} + +export interface CreateConnectorBasicArgs { + api: ConnectorApi; + createExternalService: (credentials: ExternalServiceCredential) => ExternalService; +} + +export interface CreateConnectorArgs extends CreateConnectorBasicArgs { + config: ConnectorConfiguration; + validate: ConnectorValidation; +} + +export interface CreateActionTypeArgs { + configurationUtilities: ActionsConfigurationUtilities; + executor?: ExecutorType; +} + +export interface PipedField { + key: string; + value: string; + actionType: string; + pipes: string[]; +} + +export interface PrepareFieldsForTransformArgs { + params: ApiParams; + mapping: Map; + defaultPipes?: string[]; +} + +export interface TransformFieldsArgs { + params: ExecutorActionParams; + fields: PipedField[]; + currentIncident?: ExternalServiceParams; +} + +export interface TransformerArgs { + value: string; + date?: string; + user?: string; + previousValue?: string; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/connectors/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/connectors/utils.ts new file mode 100644 index 000000000000000..7fa96923ce23275 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/connectors/utils.ts @@ -0,0 +1,231 @@ +/* + * 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, flow } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { AxiosInstance, Method, AxiosResponse } from 'axios'; + +import { ActionTypeExecutorOptions, ActionTypeExecutorResult, ActionType } from '../../types'; + +import { + ConnectorPublicConfiguration, + ConnectorSecretConfiguration, + ExecutorParamsSchema, +} from './schema'; + +import { + CreateConnectorArgs, + ConnectorPublicConfigurationType, + ConnectorSecretConfigurationType, + CreateActionTypeArgs, + ExecutorParams, + MapRecord, + AnyParams, + CreateConnectorBasicArgs, + PrepareFieldsForTransformArgs, + PipedField, + TransformFieldsArgs, + ExecutorActionParams, + Comment, +} from './types'; + +import * as transformers from './transformers'; + +import { SUPPORTED_SOURCE_FIELDS } from './constants'; + +export const normalizeMapping = (supportedFields: string[], mapping: MapRecord[]): MapRecord[] => { + // Prevent prototype pollution and remove unsupported fields + return mapping.filter( + m => m.source !== '__proto__' && m.target !== '__proto__' && supportedFields.includes(m.source) + ); +}; + +export const buildMap = (mapping: MapRecord[]): Map => { + return normalizeMapping(SUPPORTED_SOURCE_FIELDS, mapping).reduce((fieldsMap, field) => { + const { source, target, actionType } = field; + fieldsMap.set(source, { target, actionType }); + fieldsMap.set(target, { target: source, actionType }); + return fieldsMap; + }, new Map()); +}; + +export const mapParams = (params: any, mapping: Map) => { + return Object.keys(params).reduce((prev: AnyParams, curr: string): AnyParams => { + const field = mapping.get(curr); + if (field) { + prev[field.target] = params[curr]; + } + return prev; + }, {}); +}; + +export const createConnectorExecutor = ({ + api, + createExternalService, +}: CreateConnectorBasicArgs) => async ( + execOptions: ActionTypeExecutorOptions +): Promise => { + const actionId = execOptions.actionId; + const { + apiUrl, + casesConfiguration: { mapping: configurationMapping }, + } = execOptions.config as ConnectorPublicConfigurationType; + + const { username, password } = execOptions.secrets as ConnectorSecretConfigurationType; + const params = execOptions.params as ExecutorParams; + const { action, actionParams } = params; + const { comments, externalCaseId, ...restParams } = actionParams; + + const mapping = buildMap(configurationMapping); + const externalCase = mapParams(restParams, mapping); + + const res: Pick & + Pick = { + status: 'ok', + actionId, + }; + + const externalService = createExternalService({ url: apiUrl, username, password }); + + if (!api[action]) { + throw new Error('[Action][Connector] Unsupported action type'); + } + + const data = await api[action]({ + externalService, + mapping, + params: { ...actionParams, externalCase }, + }); + + return { + ...res, + data, + }; +}; + +export const createConnector = ({ + api, + config, + validate, + createExternalService, +}: CreateConnectorArgs) => { + return ({ + configurationUtilities, + executor = createConnectorExecutor({ api, createExternalService }), + }: CreateActionTypeArgs): ActionType => ({ + id: config.id, + name: config.name, + minimumLicenseRequired: 'platinum', + validate: { + config: schema.object(ConnectorPublicConfiguration, { + validate: curry(validate.config)(configurationUtilities), + }), + secrets: schema.object(ConnectorSecretConfiguration, { + validate: curry(validate.secrets)(configurationUtilities), + }), + params: ExecutorParamsSchema, + }, + executor, + }); +}; + +export const throwIfNotAlive = ( + status: number, + contentType: string, + validStatusCodes: number[] = [200, 201] +) => { + if (!validStatusCodes.includes(status) || !contentType.includes('application/json')) { + throw new Error('Instance is not alive.'); + } +}; + +export const request = async ({ + axios, + url, + method = 'get', + data = {}, +}: { + axios: AxiosInstance; + url: string; + method?: Method; + data?: any; +}): Promise => { + const res = await axios(url, { method, data }); + throwIfNotAlive(res.status, res.headers['content-type']); + return res; +}; + +export const patch = ({ + axios, + url, + data, +}: { + axios: AxiosInstance; + url: string; + data: any; +}): Promise => { + return request({ + axios, + url, + method: 'patch', + data, + }); +}; + +export const addTimeZoneToDate = (date: string, timezone = 'GMT'): string => { + return `${date} ${timezone}`; +}; + +export const prepareFieldsForTransformation = ({ + params, + mapping, + defaultPipes = ['informationCreated'], +}: PrepareFieldsForTransformArgs): PipedField[] => { + return Object.keys(params.externalCase) + .filter(p => mapping.get(p).actionType !== 'nothing') + .map(p => ({ + key: p, + value: params.externalCase[p], + actionType: mapping.get(p).actionType, + pipes: [...defaultPipes], + })) + .map(p => ({ + ...p, + pipes: p.actionType === 'append' ? [...p.pipes, 'append'] : p.pipes, + })); +}; + +const t = { ...transformers } as { [index: string]: Function }; // TODO: Find a better solution if exists. + +export const transformFields = ({ params, fields, currentIncident }: TransformFieldsArgs) => { + return fields.reduce((prev, cur) => { + const transform = flow(...cur.pipes.map(p => t[p])); + prev[cur.key] = transform({ + value: cur.value, + date: params.updatedAt ?? params.createdAt, + user: + params.updatedBy != null + ? params.updatedBy.fullName ?? params.updatedBy.username + : params.createdBy.fullName ?? params.createdBy.username, + previousValue: currentIncident ? currentIncident[cur.key] : '', + }).value; + return prev; + }, {} as any); +}; + +export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => { + return comments.map(c => ({ + ...c, + comment: flow(...pipes.map(p => t[p]))({ + value: c.comment, + date: c.updatedAt ?? c.createdAt, + user: + c.updatedBy != null + ? c.updatedBy.fullName ?? c.updatedBy.username + : c.createdBy.fullName ?? c.createdBy.username, + }).value, + })); +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index a92a279d0843991..04e8f64582c6d64 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -12,10 +12,13 @@ import { getActionType as getEmailActionType } from './email'; import { getActionType as getIndexActionType } from './es_index'; import { getActionType as getPagerDutyActionType } from './pagerduty'; import { getActionType as getServerLogActionType } from './server_log'; -import { getActionType as getServiceNowActionType } from './servicenow'; +// import { getActionType as getServiceNowActionType } from './servicenow'; import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; +// Connectors +import { getServiceNowConnector } from './connectors'; + export function registerBuiltInActionTypes({ actionsConfigUtils: configurationUtilities, actionTypeRegistry, @@ -29,7 +32,9 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getIndexActionType({ logger })); actionTypeRegistry.register(getPagerDutyActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServerLogActionType({ logger })); - actionTypeRegistry.register(getServiceNowActionType({ configurationUtilities })); actionTypeRegistry.register(getSlackActionType({ configurationUtilities })); actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); + + // Connectors + actionTypeRegistry.register(getServiceNowConnector({ configurationUtilities })); }