From 624a2bf9fbd7ca1d0feff01a678bdc947d921453 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 25 Jun 2020 15:49:17 +0300 Subject: [PATCH 1/3] Create types and schema --- .../resilient/case_schema.ts | 36 +++++++ .../resilient/case_types.ts | 68 ++++++++++++ .../builtin_action_types/resilient/schema.ts | 66 ++++++++++++ .../builtin_action_types/resilient/types.ts | 100 ++++++++++++++++++ 4 files changed, 270 insertions(+) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/case_schema.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/case_types.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/case_schema.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/case_schema.ts new file mode 100644 index 0000000000000..2df8c8156cde8 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/case_schema.ts @@ -0,0 +1,36 @@ +/* + * 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 IncidentConfigurationSchema = schema.object({ + mapping: schema.arrayOf(MapRecordSchema), +}); + +export const EntityInformation = { + createdAt: schema.maybe(schema.string()), + createdBy: schema.maybe(schema.any()), + updatedAt: schema.nullable(schema.string()), + updatedBy: schema.nullable(schema.any()), +}; + +export const CommentSchema = schema.object({ + commentId: schema.string(), + comment: schema.string(), + ...EntityInformation, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/case_types.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/case_types.ts new file mode 100644 index 0000000000000..b32971c25308a --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/case_types.ts @@ -0,0 +1,68 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { TypeOf } from '@kbn/config-schema'; +import { IncidentConfigurationSchema, MapRecordSchema } from './case_schema'; +import { + ExecutorSubActionGetIncidentParamsSchema, + ExecutorSubActionHandshakeParamsSchema, +} from './schema'; +import { + ExternalServiceIncidentResponse, + PushToServiceApiParams, + ExternalServiceParams, +} from './types'; + +export interface AnyParams { + [index: string]: string | number | object | undefined | null; +} + +export interface CreateCommentRequest { + [key: string]: string; +} + +export type IncidentConfiguration = TypeOf; +export type MapRecord = TypeOf; + +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} + +export type ExecutorSubActionGetIncidentParams = TypeOf< + typeof ExecutorSubActionGetIncidentParamsSchema +>; + +export type ExecutorSubActionHandshakeParams = TypeOf< + typeof ExecutorSubActionHandshakeParamsSchema +>; + +export interface PushToServiceResponse extends ExternalServiceIncidentResponse { + comments?: ExternalServiceCommentResponse[]; +} + +export interface PipedField { + key: string; + value: string; + actionType: string; + pipes: string[]; +} + +export interface TransformFieldsArgs { + params: PushToServiceApiParams; + 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/resilient/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts new file mode 100644 index 0000000000000..c2838291c4cda --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.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 { schema } from '@kbn/config-schema'; +import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from './case_schema'; + +export const ExternalIncidentServiceConfiguration = { + apiUrl: schema.string(), + orgId: schema.string(), + // TODO: to remove - set it optional for the current stage to support Case ServiceNow implementation + incidentConfiguration: schema.nullable(IncidentConfigurationSchema), +}; + +export const ExternalIncidentServiceConfigurationSchema = schema.object( + ExternalIncidentServiceConfiguration +); + +export const ExternalIncidentServiceSecretConfiguration = { + apiKeyId: schema.string(), + apiKeySecret: schema.string(), +}; + +export const ExternalIncidentServiceSecretConfigurationSchema = schema.object( + ExternalIncidentServiceSecretConfiguration +); + +export const ExecutorSubActionSchema = schema.oneOf([ + schema.literal('getIncident'), + schema.literal('pushToService'), + schema.literal('handshake'), +]); + +export const ExecutorSubActionPushParamsSchema = schema.object({ + savedObjectId: schema.string(), + title: schema.string(), + description: schema.nullable(schema.string()), + externalId: schema.nullable(schema.string()), + // TODO: remove later - need for support Case push multiple comments + comments: schema.maybe(schema.arrayOf(CommentSchema)), + ...EntityInformation, +}); + +export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ + externalId: schema.string(), +}); + +// Reserved for future implementation +export const ExecutorSubActionHandshakeParamsSchema = schema.object({}); + +export const ExecutorParamsSchema = schema.oneOf([ + schema.object({ + subAction: schema.literal('getIncident'), + subActionParams: ExecutorSubActionGetIncidentParamsSchema, + }), + schema.object({ + subAction: schema.literal('handshake'), + subActionParams: ExecutorSubActionHandshakeParamsSchema, + }), + schema.object({ + subAction: schema.literal('pushToService'), + subActionParams: ExecutorSubActionPushParamsSchema, + }), +]); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts new file mode 100644 index 0000000000000..f325091118551 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.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. + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { TypeOf } from '@kbn/config-schema'; +import { + ExternalIncidentServiceConfigurationSchema, + ExternalIncidentServiceSecretConfigurationSchema, + ExecutorParamsSchema, + ExecutorSubActionPushParamsSchema, + ExecutorSubActionGetIncidentParamsSchema, + ExecutorSubActionHandshakeParamsSchema, +} from './schema'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { IncidentConfigurationSchema } from './case_schema'; +import { PushToServiceResponse } from './case_types'; + +export type ResilientPublicConfigurationType = TypeOf< + typeof ExternalIncidentServiceConfigurationSchema +>; +export type ResilientSecretConfigurationType = TypeOf< + typeof ExternalIncidentServiceSecretConfigurationSchema +>; + +export interface CreateCommentRequest { + [key: string]: string; +} + +export type ExecutorParams = TypeOf; +export type ExecutorSubActionPushParams = TypeOf; + +export type IncidentConfiguration = TypeOf; + +export interface ExternalServiceCredentials { + config: Record; + secrets: Record; +} + +export interface ExternalServiceValidation { + config: (configurationUtilities: ActionsConfigurationUtilities, configObject: any) => void; + secrets: (configurationUtilities: ActionsConfigurationUtilities, secrets: any) => void; +} + +export interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} + +export interface ExternalServiceParams { + [index: string]: any; +} + +export interface ExternalService { + getIncident: (id: string) => Promise; + createIncident: (params: ExternalServiceParams) => Promise; + updateIncident: (params: ExternalServiceParams) => Promise; + findIncidents: (params?: Record) => Promise; +} + +export interface PushToServiceApiParams extends ExecutorSubActionPushParams { + externalObject: Record; +} + +export interface ExternalServiceApiHandlerArgs { + externalService: ExternalService; + mapping: Map | null; +} + +export type ExecutorSubActionGetIncidentParams = TypeOf< + typeof ExecutorSubActionGetIncidentParamsSchema +>; + +export type ExecutorSubActionHandshakeParams = TypeOf< + typeof ExecutorSubActionHandshakeParamsSchema +>; + +export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: PushToServiceApiParams; + secrets: Record; +} + +export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: ExecutorSubActionGetIncidentParams; +} + +export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs { + params: ExecutorSubActionHandshakeParams; +} + +export interface ExternalServiceApi { + handshake: (args: HandshakeApiHandlerArgs) => Promise; + pushToService: (args: PushToServiceApiHandlerArgs) => Promise; + getIncident: (args: GetIncidentApiHandlerArgs) => Promise; +} From c245ed9b5dfd81241d23aa344960e194a433a346 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 25 Jun 2020 16:45:18 +0300 Subject: [PATCH 2/3] Add api and service --- .../server/builtin_action_types/case/api.ts | 2 +- .../builtin_action_types/case/schema.ts | 2 +- .../server/builtin_action_types/case/types.ts | 2 +- .../builtin_action_types/case/utils.test.ts | 24 +-- .../server/builtin_action_types/case/utils.ts | 6 +- .../server/builtin_action_types/jira/mocks.ts | 2 +- .../builtin_action_types/resilient/api.ts | 140 +++++++++++++ .../builtin_action_types/resilient/index.ts | 110 ++++++++++ .../builtin_action_types/resilient/service.ts | 197 ++++++++++++++++++ .../resilient/translations.ts | 74 +++++++ .../builtin_action_types/resilient/types.ts | 26 ++- .../resilient/validators.ts | 43 ++++ 12 files changed, 608 insertions(+), 20 deletions(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/api.ts b/x-pack/plugins/actions/server/builtin_action_types/case/api.ts index 6dc8a9cc9af6a..de4b7edaed3da 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/api.ts @@ -41,7 +41,7 @@ const pushToServiceHandler = async ({ } const fields = prepareFieldsForTransformation({ - params, + externalCase: params.externalCase, mapping, defaultPipes, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts index 33b2ad6d18684..f47686c911ff0 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/schema.ts @@ -67,7 +67,7 @@ export const ExecutorSubActionSchema = schema.oneOf([ ]); export const ExecutorSubActionPushParamsSchema = schema.object({ - caseId: schema.string(), + savedObjectId: schema.string(), title: schema.string(), description: schema.nullable(schema.string()), comments: schema.nullable(schema.arrayOf(CommentSchema)), diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/types.ts b/x-pack/plugins/actions/server/builtin_action_types/case/types.ts index 992b2cb16fb06..de96864d0b295 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/types.ts @@ -144,7 +144,7 @@ export interface PipedField { } export interface PrepareFieldsForTransformArgs { - params: PushToServiceApiParams; + externalCase: Record; mapping: Map; defaultPipes?: string[]; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts index 017fc73efae20..d403451414b85 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts @@ -63,7 +63,7 @@ const maliciousMapping: MapRecord[] = [ ]; const fullParams: PushToServiceApiParams = { - caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', title: 'a title', description: 'a description', createdAt: '2020-03-13T08:34:53.450Z', @@ -132,7 +132,7 @@ describe('buildMap', () => { describe('mapParams', () => { test('maps params correctly', () => { const params = { - caseId: '123', + savedObjectId: '123', incidentId: '456', title: 'Incident title', description: 'Incident description', @@ -148,7 +148,7 @@ describe('mapParams', () => { test('do not add fields not in mapping', () => { const params = { - caseId: '123', + savedObjectId: '123', incidentId: '456', title: 'Incident title', description: 'Incident description', @@ -164,7 +164,7 @@ describe('mapParams', () => { describe('prepareFieldsForTransformation', () => { test('prepare fields with defaults', () => { const res = prepareFieldsForTransformation({ - params: fullParams, + externalCase: fullParams.externalCase, mapping: finalMapping, }); expect(res).toEqual([ @@ -185,7 +185,7 @@ describe('prepareFieldsForTransformation', () => { test('prepare fields with default pipes', () => { const res = prepareFieldsForTransformation({ - params: fullParams, + externalCase: fullParams.externalCase, mapping: finalMapping, defaultPipes: ['myTestPipe'], }); @@ -209,7 +209,7 @@ describe('prepareFieldsForTransformation', () => { describe('transformFields', () => { test('transform fields for creation correctly', () => { const fields = prepareFieldsForTransformation({ - params: fullParams, + externalCase: fullParams.externalCase, mapping: finalMapping, }); @@ -226,8 +226,8 @@ describe('transformFields', () => { test('transform fields for update correctly', () => { const fields = prepareFieldsForTransformation({ - params: { - ...fullParams, + externalCase: { + ...fullParams.externalCase, updatedAt: '2020-03-15T08:34:53.450Z', updatedBy: { username: 'anotherUser', @@ -262,7 +262,7 @@ describe('transformFields', () => { test('add newline character to descripton', () => { const fields = prepareFieldsForTransformation({ - params: fullParams, + externalCase: fullParams.externalCase, mapping: finalMapping, defaultPipes: ['informationUpdated'], }); @@ -280,7 +280,7 @@ describe('transformFields', () => { test('append username if fullname is undefined when create', () => { const fields = prepareFieldsForTransformation({ - params: fullParams, + externalCase: fullParams.externalCase, mapping: finalMapping, }); @@ -300,8 +300,8 @@ describe('transformFields', () => { test('append username if fullname is undefined when update', () => { const fields = prepareFieldsForTransformation({ - params: { - ...fullParams, + externalCase: { + ...fullParams.externalCase, updatedAt: '2020-03-15T08:34:53.450Z', updatedBy: { username: 'anotherUser', diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts index dd8d971b7df44..0f4ac8a576793 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.ts @@ -182,17 +182,17 @@ export const addTimeZoneToDate = (date: string, timezone = 'GMT'): string => { }; export const prepareFieldsForTransformation = ({ - params, + externalCase, mapping, defaultPipes = ['informationCreated'], }: PrepareFieldsForTransformArgs): PipedField[] => { - return Object.keys(params.externalCase) + return Object.keys(externalCase) .filter((p) => mapping.get(p)?.actionType != null && mapping.get(p)?.actionType !== 'nothing') .map((p) => { const actionType = mapping.get(p)?.actionType ?? 'nothing'; return { key: p, - value: params.externalCase[p], + value: externalCase[p], actionType, pipes: actionType === 'append' ? [...defaultPipes, 'append'] : defaultPipes, }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts index 3ae0e9db36de0..709d490a5227f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/mocks.ts @@ -88,7 +88,7 @@ mapping.set('summary', { }); const executorParams: ExecutorSubActionPushParams = { - caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', externalId: 'incident-3', createdAt: '2020-04-27T10:59:46.202Z', createdBy: { fullName: 'Elastic User', username: 'elastic' }, diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts new file mode 100644 index 0000000000000..cb20cb48d610e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts @@ -0,0 +1,140 @@ +/* + * 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 { flow } from 'lodash'; +import { + ExternalServiceParams, + PushToServiceApiHandlerArgs, + HandshakeApiHandlerArgs, + GetIncidentApiHandlerArgs, + ExternalServiceApi, +} from './types'; + +// TODO: to remove, need to support Case +import { transformers, Transformer } from '../case/transformers'; +import { PushToServiceResponse, TransformFieldsArgs } from './case_types'; +import { prepareFieldsForTransformation } from '../case/utils'; + +const handshakeHandler = async ({ + externalService, + mapping, + params, +}: HandshakeApiHandlerArgs) => {}; +const getIncidentHandler = async ({ + externalService, + mapping, + params, +}: GetIncidentApiHandlerArgs) => {}; + +const pushToServiceHandler = async ({ + externalService, + mapping, + params, + secrets, +}: PushToServiceApiHandlerArgs): Promise => { + const { externalId, comments } = params; + const updateIncident = externalId ? true : false; + const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated']; + let currentIncident: ExternalServiceParams | undefined; + let res: PushToServiceResponse; + + if (externalId) { + currentIncident = await externalService.getIncident(externalId); + } + + let incident = {}; + // TODO: should be removed later but currently keep it for the Case implementation support + if (mapping) { + const fields = prepareFieldsForTransformation({ + externalCase: params.externalObject, + mapping, + defaultPipes, + }); + + incident = transformFields({ + params, + fields, + currentIncident, + }); + } else { + incident = { ...params, name: params.title }; + } + + if (updateIncident) { + res = await externalService.updateIncident({ + incidentId: externalId, + incident, + }); + } else { + res = await externalService.createIncident({ + incident: { + ...incident, + caller_id: secrets.username, + }, + }); + } + + // TODO: should temporary keep comments for a Case usage + if ( + comments && + Array.isArray(comments) && + comments.length > 0 && + mapping && + mapping.get('comments')?.actionType !== 'nothing' + ) { + res.comments = []; + + const fieldsKey = mapping.get('comments')?.target ?? 'comments'; + for (const currentComment of comments) { + await externalService.updateIncident({ + incidentId: res.id, + incident: { + ...incident, + [fieldsKey]: currentComment.comment, + }, + }); + res.comments = [ + ...(res.comments ?? []), + { + commentId: currentComment.commentId, + pushedDate: res.pushedDate, + }, + ]; + } + } + return res; +}; + +export const transformFields = ({ + params, + fields, + currentIncident, +}: TransformFieldsArgs): Record => { + return fields.reduce((prev, cur) => { + const transform = flow(...cur.pipes.map((p) => transformers[p])); + return { + ...prev, + [cur.key]: transform({ + value: cur.value, + date: params.updatedAt ?? params.createdAt, + user: + (params.updatedBy != null + ? params.updatedBy.fullName + ? params.updatedBy.fullName + : params.updatedBy.username + : params.createdBy.fullName + ? params.createdBy.fullName + : params.createdBy.username) ?? '', + previousValue: currentIncident ? currentIncident[cur.key] : '', + }).value, + }; + }, {}); +}; + +export const api: ExternalServiceApi = { + handshake: handshakeHandler, + pushToService: pushToServiceHandler, + getIncident: getIncidentHandler, +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts new file mode 100644 index 0000000000000..a9ab0aff9c2e3 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts @@ -0,0 +1,110 @@ +/* + * 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 { validate } from './validators'; +import { + ExternalIncidentServiceConfiguration, + ExternalIncidentServiceSecretConfiguration, + ExecutorParamsSchema, +} from './schema'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types'; +import { createExternalService } from './service'; +import { api } from './api'; +import { ExecutorParams, ExecutorSubActionPushParams } from './types'; +import * as i18n from './translations'; +import { Logger } from '../../../../../../src/core/server'; + +// TODO: to remove, need to support Case +import { buildMap, mapParams } from '../case/utils'; +import { PushToServiceResponse } from './case_types'; + +interface GetActionTypeParams { + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; +} + +// action type definition +export function getActionType(params: GetActionTypeParams): ActionType { + const { logger, configurationUtilities } = params; + return { + id: '.servicenow', + minimumLicenseRequired: 'platinum', + name: i18n.NAME, + validate: { + config: schema.object(ExternalIncidentServiceConfiguration, { + validate: curry(validate.config)(configurationUtilities), + }), + secrets: schema.object(ExternalIncidentServiceSecretConfiguration, { + validate: curry(validate.secrets)(configurationUtilities), + }), + params: ExecutorParamsSchema, + }, + executor: curry(executor)({ logger }), + }; +} + +// action executor + +async function executor( + { logger }: { logger: Logger }, + execOptions: ActionTypeExecutorOptions +): Promise { + const { actionId, config, params, secrets } = execOptions; + const { subAction, subActionParams } = params as ExecutorParams; + let data: PushToServiceResponse | null = null; + + const res: Pick & + Pick = { + status: 'ok', + actionId, + }; + + const externalService = createExternalService({ + config, + secrets, + }); + + if (!api[subAction]) { + const errorMessage = '[Action][ExternalService] Unsupported subAction type.'; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (subAction !== 'pushToService') { + const errorMessage = '[Action][ExternalService] subAction not implemented.'; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (subAction === 'pushToService') { + const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; + + const { comments, externalId, ...restParams } = pushToServiceParams; + const mapping = config.incidentConfiguration + ? buildMap(config.incidentConfiguration.mapping) + : null; + const externalObject = + config.incidentConfiguration && mapping ? mapParams(restParams, mapping) : {}; + + data = await api.pushToService({ + externalService, + mapping, + params: { ...pushToServiceParams, externalObject }, + secrets, + }); + + logger.debug(`response push to service for incident id: ${data?.id}`); + } + + return { + ...res, + data: data ?? {}, + }; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts new file mode 100644 index 0000000000000..d7e90b580b9e5 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -0,0 +1,197 @@ +/* + * 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 https from 'https'; + +import { + ExternalServiceParams, + ExternalService, + ExternalServiceCredentials, + ResilientPublicConfigurationType, + ResilientSecretConfigurationType, + UpdateFieldText, + UpdateFieldTextArea, + UpdateIncidentRequest, +} from './types'; + +import * as i18n from './translations'; +import { getErrorMessage, request } from '../case/utils'; + +const BASE_URL = `rest`; +const INCIDENT_URL = `incidents`; +const COMMENT_URL = `comments`; + +const VIEW_INCIDENT_URL = `#incidents`; + +export const getValueTextContent = ( + field: string, + value: string +): UpdateFieldText | UpdateFieldTextArea => { + if (field === 'description') { + return { + textarea: { + format: 'html', + content: value, + }, + }; + } + + return { + text: value, + }; +}; + +export const formatUpdateRequest = ({ + oldIncident, + newIncident, +}: ExternalServiceParams): UpdateIncidentRequest => { + return { + changes: Object.keys(newIncident).map((key) => ({ + field: { name: key }, + old_value: getValueTextContent(key, oldIncident[key]), + new_value: getValueTextContent(key, newIncident[key]), + })), + }; +}; + +export const createExternalService = ({ + config, + secrets, +}: ExternalServiceCredentials): ExternalService => { + const { apiUrl: url, orgId } = config as ResilientPublicConfigurationType; + const { apiKeyId, apiKeySecret } = secrets as ResilientSecretConfigurationType; + + if (!url || !orgId || !apiKeyId || !apiKeySecret) { + throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); + } + + const incidentUrl = `${url}/${BASE_URL}/orgs/${orgId}/${INCIDENT_URL}`; + const commentUrl = `${incidentUrl}/{inc_id}/${COMMENT_URL}`; + const axiosInstance = axios.create({ + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + auth: { username: apiKeyId, password: apiKeySecret }, + }); + + const getIncidentViewURL = (key: string) => { + return `${url}/${VIEW_INCIDENT_URL}/${key}`; + }; + + const getCommentsURL = (incidentId: string) => { + return commentUrl.replace('{inc_id}', incidentId); + }; + + const getIncident = async (id: string) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}/${id}?text_content_output_format=objects_convert`, + }); + + return { ...res.data, description: res.data.description?.content ?? '' }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `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, + description: { + format: 'html', + content: incident.description ?? '', + }, + discovered_date: Date.now(), + }, + }); + + return { + title: `${res.data.id}`, + id: `${res.data.id}`, + pushedDate: new Date(res.data.create_date).toISOString(), + url: getIncidentViewURL(res.data.id), + }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`) + ); + } + }; + + const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + try { + const latestIncident = await getIncident(incidentId); + + const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident: incident }); + const res = await request({ + axios: axiosInstance, + method: 'patch', + url: `${incidentUrl}/${incidentId}`, + data, + }); + + if (!res.data.success) { + throw new Error(res.data.message); + } + + const updatedIncident = await getIncident(incidentId); + + return { + title: `${updatedIncident.id}`, + id: `${updatedIncident.id}`, + pushedDate: new Date(updatedIncident.inc_last_modified_date).toISOString(), + url: getIncidentViewURL(updatedIncident.id), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to update incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => { + try { + const res = await request({ + axios: axiosInstance, + method: 'post', + url: getCommentsURL(incidentId), + data: { text: { format: 'text', content: comment.comment } }, + }); + + return { + commentId: comment.commentId, + externalCommentId: res.data.id, + pushedDate: new Date(res.data.create_date).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `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/resilient/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts new file mode 100644 index 0000000000000..5b93629a0b5a5 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts @@ -0,0 +1,74 @@ +/* + * 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.resilientTitle', { + defaultMessage: 'IBM Resilient', +}); + +export const WHITE_LISTED_ERROR = (message: string) => + i18n.translate('xpack.actions.builtin.configuration.apiWhitelistError', { + defaultMessage: 'error configuring connector action: {message}', + values: { + message, + }, + }); + +// TODO: remove when Case mappings will be removed +export const MAPPING_EMPTY = i18n.translate('xpack.actions.builtin.configuration.emptyMapping', { + defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty', +}); + +export const RESILIENT_DESC = i18n.translate('xpack.actions.builtin.resilientSelectMessageText', { + defaultMessage: 'Push or update SIEM case data to a new issue in resilient', +}); + +export const RESILIENT_PROJECT_KEY_LABEL = i18n.translate('xpack.actions.builtin.resilientOrgId', { + defaultMessage: 'Organization Id', +}); + +export const RESILIENT_PROJECT_KEY_REQUIRED = i18n.translate( + 'xpack.actions.builtin.resilientRequiredOrgIdTextField', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_API_KEY_ID_LABEL = i18n.translate( + 'xpack.actions.builtin.resilientApiKeyId', + { + defaultMessage: 'API key id', + } +); + +export const RESILIENT_API_KEY_ID_REQUIRED = i18n.translate( + 'xpack.actions.builtin.resilientRequiredApiKeyIdTextField', + { + defaultMessage: 'API key id is required', + } +); + +export const RESILIENT_API_KEY_SECRET_LABEL = i18n.translate( + 'xpack.actions.builtin.resilientApiKeySecret', + { + defaultMessage: 'API key secret', + } +); + +export const RESILIENT_API_KEY_SECRET_REQUIRED = i18n.translate( + 'xpack.actions.builtin.resilientRequiredApiKeySecretTextField', + { + defaultMessage: 'API key secret is required', + } +); + +export const MAPPING_FIELD_NAME = i18n.translate( + 'xpack.securitySolution.case.configureCases.mappingFieldName', + { + defaultMessage: 'Name', + } +); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts index f325091118551..1525446b48762 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts @@ -56,11 +56,17 @@ export interface ExternalServiceParams { [index: string]: any; } +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} + export interface ExternalService { getIncident: (id: string) => Promise; createIncident: (params: ExternalServiceParams) => Promise; updateIncident: (params: ExternalServiceParams) => Promise; - findIncidents: (params?: Record) => Promise; + createComment: (params: ExternalServiceParams) => Promise; } export interface PushToServiceApiParams extends ExecutorSubActionPushParams { @@ -98,3 +104,21 @@ export interface ExternalServiceApi { pushToService: (args: PushToServiceApiHandlerArgs) => Promise; getIncident: (args: GetIncidentApiHandlerArgs) => Promise; } + +export interface UpdateFieldText { + text: string; +} + +export interface UpdateFieldTextArea { + textarea: { format: 'html' | 'text'; content: string }; +} + +interface UpdateField { + field: { name: string }; + old_value: UpdateFieldText | UpdateFieldTextArea; + new_value: UpdateFieldText | UpdateFieldTextArea; +} + +export interface UpdateIncidentRequest { + changes: UpdateField[]; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts new file mode 100644 index 0000000000000..4571bc0a248db --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.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 { isEmpty } from 'lodash'; +import { ActionsConfigurationUtilities } from '../../actions_config'; +import { + ResilientPublicConfigurationType, + ResilientSecretConfigurationType, + ExternalServiceValidation, +} from './types'; + +import * as i18n from './translations'; + +export const validateCommonConfig = ( + configurationUtilities: ActionsConfigurationUtilities, + configObject: ResilientPublicConfigurationType +) => { + if ( + configObject.incidentConfiguration !== null && + isEmpty(configObject.incidentConfiguration.mapping) + ) { + return i18n.MAPPING_EMPTY; + } + + try { + configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); + } catch (whitelistError) { + return i18n.WHITE_LISTED_ERROR(whitelistError.message); + } +}; + +export const validateCommonSecrets = ( + configurationUtilities: ActionsConfigurationUtilities, + secrets: ResilientSecretConfigurationType +) => {}; + +export const validate: ExternalServiceValidation = { + config: validateCommonConfig, + secrets: validateCommonSecrets, +}; From 7c32bac2218aa2f1b6c509a1cdcddda4e7a047b0 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 25 Jun 2020 21:28:42 +0300 Subject: [PATCH 3/3] Create Resilient flyout --- .../server/builtin_action_types/index.ts | 2 + .../builtin_action_types/resilient/index.ts | 2 +- .../case/common/api/cases/configure.ts | 8 +- .../case/common/api/connectors/index.ts | 1 + .../case/common/api/connectors/resilient.ts | 15 ++ x-pack/plugins/case/common/constants.ts | 2 +- .../public/common/lib/connectors/config.ts | 3 + .../components/builtin_action_types/index.ts | 2 + .../resilient/case_mappings/field_mapping.tsx | 147 +++++++++++++ .../case_mappings/field_mapping_row.tsx | 80 +++++++ .../resilient/case_mappings/translations.ts | 190 +++++++++++++++++ .../resilient/case_mappings/types.ts | 63 ++++++ .../resilient/case_mappings/utils.ts | 40 ++++ .../builtin_action_types/resilient/config.ts | 38 ++++ .../builtin_action_types/resilient/index.ts | 7 + .../builtin_action_types/resilient/logo.svg | 9 + .../resilient/resilient.tsx | 73 +++++++ .../resilient/resilient_connectors.tsx | 197 ++++++++++++++++++ .../resilient/resilient_params.tsx | 159 ++++++++++++++ .../resilient/translations.ts | 112 ++++++++++ .../builtin_action_types/resilient/types.ts | 48 +++++ 21 files changed, 1195 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/case/common/api/connectors/resilient.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/field_mapping.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/field_mapping_row.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/types.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/utils.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/index.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/logo.svg create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/translations.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/types.ts 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 6ba4d7cfc7de0..2a53f12d69f02 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -16,6 +16,7 @@ import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; import { getActionType as getServiceNowActionType } from './servicenow'; import { getActionType as getJiraActionType } from './jira'; +import { getActionType as getResilientActionType } from './resilient'; export function registerBuiltInActionTypes({ actionsConfigUtils: configurationUtilities, @@ -34,4 +35,5 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServiceNowActionType({ configurationUtilities })); actionTypeRegistry.register(getJiraActionType({ configurationUtilities })); + actionTypeRegistry.register(getResilientActionType({ logger, configurationUtilities })); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts index a9ab0aff9c2e3..c0f663d839cbb 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts @@ -34,7 +34,7 @@ interface GetActionTypeParams { export function getActionType(params: GetActionTypeParams): ActionType { const { logger, configurationUtilities } = params; return { - id: '.servicenow', + id: '.resilient', minimumLicenseRequired: 'platinum', name: i18n.NAME, validate: { diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index 7d20011a428cf..38fff5b190f25 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -10,6 +10,7 @@ import { ActionResult } from '../../../../actions/common'; import { UserRT } from '../user'; import { JiraFieldsRT } from '../connectors/jira'; import { ServiceNowFieldsRT } from '../connectors/servicenow'; +import { ResilientFieldsRT } from '../connectors/resilient'; /* * This types below are related to the service now configuration @@ -29,7 +30,12 @@ const CaseFieldRT = rt.union([ rt.literal('comments'), ]); -const ThirdPartyFieldRT = rt.union([JiraFieldsRT, ServiceNowFieldsRT, rt.literal('not_mapped')]); +const ThirdPartyFieldRT = rt.union([ + JiraFieldsRT, + ServiceNowFieldsRT, + ResilientFieldsRT, + rt.literal('not_mapped'), +]); export const CasesConfigurationMapsRT = rt.type({ source: CaseFieldRT, diff --git a/x-pack/plugins/case/common/api/connectors/index.ts b/x-pack/plugins/case/common/api/connectors/index.ts index c1fc284c938b7..0a7840d3aba22 100644 --- a/x-pack/plugins/case/common/api/connectors/index.ts +++ b/x-pack/plugins/case/common/api/connectors/index.ts @@ -6,3 +6,4 @@ export * from './jira'; export * from './servicenow'; +export * from './resilient'; diff --git a/x-pack/plugins/case/common/api/connectors/resilient.ts b/x-pack/plugins/case/common/api/connectors/resilient.ts new file mode 100644 index 0000000000000..c7e2f19809140 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/resilient.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const ResilientFieldsRT = rt.union([ + rt.literal('name'), + rt.literal('description'), + rt.literal('comments'), +]); + +export type ResilientFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index 819d4110e168d..90fa3892817b1 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -28,4 +28,4 @@ export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`; export const ACTION_URL = '/api/actions'; export const ACTION_TYPES_URL = '/api/actions/list_action_types'; -export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira']; +export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira', '.resilient']; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts index d8b55665f7768..b36c4cdf9fa90 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { connectorConfiguration as resilientConnectorConfig } from '../../../../../triggers_actions_ui/public/application/components/builtin_action_types/resilient/config'; import { connector as serviceNowConnectorConfig } from './servicenow/config'; import { connector as jiraConnectorConfig } from './jira/config'; import { ConnectorConfiguration } from './types'; @@ -11,4 +13,5 @@ import { ConnectorConfiguration } from './types'; export const connectorsConfiguration: Record = { '.servicenow': serviceNowConnectorConfig, '.jira': jiraConnectorConfig, + '.resilient': resilientConnectorConfig as ConnectorConfiguration, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts index 8f49fa46dd54e..0f9fea7950dc8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/index.ts @@ -10,6 +10,7 @@ import { getEmailActionType } from './email'; import { getIndexActionType } from './es_index'; import { getPagerDutyActionType } from './pagerduty'; import { getWebhookActionType } from './webhook'; +import { getResilientActionType } from './resilient'; import { TypeRegistry } from '../../type_registry'; import { ActionTypeModel } from '../../../types'; @@ -24,4 +25,5 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getIndexActionType()); actionTypeRegistry.register(getPagerDutyActionType()); actionTypeRegistry.register(getWebhookActionType()); + actionTypeRegistry.register(getResilientActionType()); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/field_mapping.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/field_mapping.tsx new file mode 100644 index 0000000000000..a667613d6fa97 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/field_mapping.tsx @@ -0,0 +1,147 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { EuiFormRow, EuiFlexItem, EuiFlexGroup, EuiSuperSelectOption } from '@elastic/eui'; +import styled from 'styled-components'; + +import { CaseField, ActionType, ThirdPartyField } from '../../../../../../../case/common/api'; +import { FieldMappingRow } from './field_mapping_row'; +import * as i18n from './translations'; + +import { setActionTypeToMapping, setThirdPartyToMapping } from './utils'; +import { + ThirdPartyField as ConnectorConfigurationThirdPartyField, + AllThirdPartyFields, +} from './types'; +import { CasesConfigurationMapping } from '../types'; +import { connectorConfiguration } from '../config'; +import { createDefaultMapping } from '../resilient_connectors'; + +const FieldRowWrapper = styled.div` + margin-top: 8px; + font-size: 14px; +`; + +const actionTypeOptions: Array> = [ + { + value: 'nothing', + inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_NOTHING}, + 'data-test-subj': 'edit-update-option-nothing', + }, + { + value: 'overwrite', + inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_OVERWRITE}, + 'data-test-subj': 'edit-update-option-overwrite', + }, + { + value: 'append', + inputDisplay: <>{i18n.FIELD_MAPPING_EDIT_APPEND}, + 'data-test-subj': 'edit-update-option-append', + }, +]; + +const getThirdPartyOptions = ( + caseField: CaseField, + thirdPartyFields: Record +): Array> => + (Object.keys(thirdPartyFields) as AllThirdPartyFields[]).reduce< + Array> + >( + (acc, key) => { + if (thirdPartyFields[key].validSourceFields.includes(caseField)) { + return [ + ...acc, + { + value: key, + inputDisplay: {thirdPartyFields[key].label}, + 'data-test-subj': `dropdown-mapping-${key}`, + }, + ]; + } + return acc; + }, + [ + { + value: 'not_mapped', + inputDisplay: i18n.MAPPING_FIELD_NOT_MAPPED, + 'data-test-subj': 'dropdown-mapping-not_mapped', + }, + ] + ); + +export interface FieldMappingProps { + disabled: boolean; + mapping: CasesConfigurationMapping[] | null; + connectorActionTypeId: string; + onChangeMapping: (newMapping: CasesConfigurationMapping[]) => void; +} + +const FieldMappingComponent: React.FC = ({ + disabled, + mapping, + onChangeMapping, + connectorActionTypeId, +}) => { + const onChangeActionType = useCallback( + (caseField: CaseField, newActionType: ActionType) => { + const myMapping = mapping ?? defaultMapping; + onChangeMapping(setActionTypeToMapping(caseField, newActionType, myMapping)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [mapping] + ); + + const onChangeThirdParty = useCallback( + (caseField: CaseField, newThirdPartyField: ThirdPartyField) => { + const myMapping = mapping ?? defaultMapping; + onChangeMapping(setThirdPartyToMapping(caseField, newThirdPartyField, myMapping)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [mapping] + ); + + const selectedConnector = connectorConfiguration ?? { fields: {} }; + const defaultMapping = useMemo(() => createDefaultMapping(selectedConnector.fields), [ + selectedConnector.fields, + ]); + + return ( + <> + + + + {i18n.FIELD_MAPPING_FIRST_COL} + + + {i18n.FIELD_MAPPING_SECOND_COL} + + + {i18n.FIELD_MAPPING_THIRD_COL} + + + + + {(mapping ?? defaultMapping).map((item) => ( + + ))} + + + ); +}; + +export const FieldMapping = React.memo(FieldMappingComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/field_mapping_row.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/field_mapping_row.tsx new file mode 100644 index 0000000000000..495b47410d2f9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/field_mapping_row.tsx @@ -0,0 +1,80 @@ +/* + * 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, { useMemo } from 'react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiSuperSelect, + EuiIcon, + EuiSuperSelectOption, +} from '@elastic/eui'; + +import { capitalize } from 'lodash'; +import { CaseField, ActionType, ThirdPartyField } from '../../../../../../../case/common/api/cases'; +import { AllThirdPartyFields } from './types'; + +export interface RowProps { + id: string; + disabled: boolean; + securitySolutionField: CaseField; + thirdPartyOptions: Array>; + actionTypeOptions: Array>; + onChangeActionType: (caseField: CaseField, newActionType: ActionType) => void; + onChangeThirdParty: (caseField: CaseField, newThirdPartyField: ThirdPartyField) => void; + selectedActionType: ActionType; + selectedThirdParty: ThirdPartyField; +} + +const FieldMappingRowComponent: React.FC = ({ + id, + disabled, + securitySolutionField, + thirdPartyOptions, + actionTypeOptions, + onChangeActionType, + onChangeThirdParty, + selectedActionType, + selectedThirdParty, +}) => { + const securitySolutionFieldCapitalized = useMemo(() => capitalize(securitySolutionField), [ + securitySolutionField, + ]); + return ( + + + + + {securitySolutionFieldCapitalized} + + + + + + + + + + + + + + ); +}; + +export const FieldMappingRow = React.memo(FieldMappingRowComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/translations.ts new file mode 100644 index 0000000000000..665ccbcfa114d --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/translations.ts @@ -0,0 +1,190 @@ +/* + * 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 INCIDENT_MANAGEMENT_SYSTEM_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemTitle', + { + defaultMessage: 'Connect to external incident management system', + } +); + +export const INCIDENT_MANAGEMENT_SYSTEM_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemDesc', + { + defaultMessage: + 'You may optionally connect Security cases to an external incident management system of your choosing. This will allow you to push case data as an incident in your chosen third-party system.', + } +); + +export const INCIDENT_MANAGEMENT_SYSTEM_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.incidentManagementSystemLabel', + { + defaultMessage: 'Incident management system', + } +); + +export const NO_CONNECTOR = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.noConnector', + { + defaultMessage: 'No connector selected', + } +); + +export const ADD_NEW_CONNECTOR = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.addNewConnector', + { + defaultMessage: 'Add new connector', + } +); + +export const CASE_CLOSURE_OPTIONS_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsTitle', + { + defaultMessage: 'Case Closures', + } +); + +export const CASE_CLOSURE_OPTIONS_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsDesc', + { + defaultMessage: + 'Define how you wish Security cases to be closed. Automated case closures require an established connection to an external incident management system.', + } +); + +export const CASE_CLOSURE_OPTIONS_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsLabel', + { + defaultMessage: 'Case closure options', + } +); + +export const CASE_CLOSURE_OPTIONS_MANUAL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsManual', + { + defaultMessage: 'Manually close Security cases', + } +); + +export const CASE_CLOSURE_OPTIONS_NEW_INCIDENT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsNewIncident', + { + defaultMessage: + 'Automatically close Security cases when pushing new incident to external system', + } +); + +export const CASE_CLOSURE_OPTIONS_CLOSED_INCIDENT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.caseClosureOptionsClosedIncident', + { + defaultMessage: 'Automatically close Security cases when incident is closed in external system', + } +); + +export const FIELD_MAPPING_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingTitle', + { + defaultMessage: 'Field mappings', + } +); + +export const FIELD_MAPPING_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingDesc', + { + defaultMessage: + 'Map Security case fields when pushing data to a third-party. Field mappings require an established connection to an external incident management system.', + } +); + +export const FIELD_MAPPING_FIRST_COL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingFirstCol', + { + defaultMessage: 'Security case field', + } +); + +export const FIELD_MAPPING_SECOND_COL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingSecondCol', + { + defaultMessage: 'External incident field', + } +); + +export const FIELD_MAPPING_THIRD_COL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingThirdCol', + { + defaultMessage: 'On edit and update', + } +); + +export const FIELD_MAPPING_EDIT_NOTHING = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditNothing', + { + defaultMessage: 'Nothing', + } +); + +export const FIELD_MAPPING_EDIT_OVERWRITE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditOverwrite', + { + defaultMessage: 'Overwrite', + } +); + +export const FIELD_MAPPING_EDIT_APPEND = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.fieldMappingEditAppend', + { + defaultMessage: 'Append', + } +); + +export const CANCEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.cancelButton', + { + defaultMessage: 'Cancel', + } +); + +export const WARNING_NO_CONNECTOR_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.warningTitle', + { + defaultMessage: 'Warning', + } +); + +export const WARNING_NO_CONNECTOR_MESSAGE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.warningMessage', + { + defaultMessage: + 'The selected connector has been deleted. Either select a different connector or create a new one.', + } +); + +export const MAPPING_FIELD_NOT_MAPPED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.mappingFieldNotMapped', + { + defaultMessage: 'Not mapped', + } +); + +export const UPDATE_CONNECTOR = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.updateConnector', + { + defaultMessage: 'Update connector', + } +); + +export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string => { + return i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.updateSelectedConnector', + { + values: { connectorName }, + defaultMessage: 'Update { connectorName }', + } + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/types.ts new file mode 100644 index 0000000000000..413d792ab2f7f --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/types.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. + */ + +/* eslint-disable no-restricted-imports */ +/* eslint-disable @kbn/eslint/no-restricted-paths */ + +import { ExternalIncidentServiceConfiguration } from '../../../../../../../actions/server/builtin_action_types/case/types'; +import { IErrorObject, ActionType } from '../../../../../types'; + +import { + ActionType as ThirdPartySupportedActions, + CaseField, +} from '../../../../../../../case/common/api'; + +export { ThirdPartyField as AllThirdPartyFields } from '../../../../../../../case/common/api'; + +export { ActionType, CaseField }; + +export interface ThirdPartyField { + label: string; + validSourceFields: CaseField[]; + defaultSourceField: CaseField; + defaultActionType: ThirdPartySupportedActions; +} + +export interface ConnectorConfiguration extends ActionType { + logo: string; + fields: Record; +} + +export interface ActionConnector { + config: ExternalIncidentServiceConfiguration; + secrets: {}; +} + +export interface ActionConnectorParams { + message: string; +} + +export interface ActionConnectorValidationErrors { + apiUrl: string[]; +} + +export type Optional = Omit & Partial; + +export interface ConnectorFlyoutFormProps { + errors: IErrorObject; + action: T; + onChangeSecret: (key: string, value: string) => void; + onBlurSecret: (key: string) => void; + onChangeConfig: (key: string, value: string) => void; + onBlurConfig: (key: string) => void; +} + +export interface ConnectorFlyoutHOCProps { + ConnectorFormComponent: React.FC>; + connectorActionTypeId: string; + configKeys?: string[]; + secretKeys?: string[]; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/utils.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/utils.ts new file mode 100644 index 0000000000000..d3139e80ae3b1 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/case_mappings/utils.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ActionType, ThirdPartyField } from '../../../../../../../case/common/api'; +import { CaseField } from './types'; +import { CasesConfigurationMapping } from '../types'; + +export const setActionTypeToMapping = ( + caseField: CaseField, + newActionType: ActionType, + mapping: CasesConfigurationMapping[] +): CasesConfigurationMapping[] => { + const findItemIndex = mapping.findIndex((item) => item.source === caseField); + + if (findItemIndex >= 0) { + return [ + ...mapping.slice(0, findItemIndex), + { ...mapping[findItemIndex], actionType: newActionType }, + ...mapping.slice(findItemIndex + 1), + ]; + } + + return [...mapping]; +}; + +export const setThirdPartyToMapping = ( + caseField: CaseField, + newThirdPartyField: ThirdPartyField, + mapping: CasesConfigurationMapping[] +): CasesConfigurationMapping[] => + mapping.map((item) => { + if (item.source !== caseField && item.target === newThirdPartyField) { + return { ...item, target: 'not_mapped' }; + } else if (item.source === caseField) { + return { ...item, target: newThirdPartyField }; + } + return item; + }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts new file mode 100644 index 0000000000000..6e69f7b995de5 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.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 * as i18n from './translations'; +import logo from './logo.svg'; + +export const connectorConfiguration = { + id: '.resilient', + name: i18n.RESILIENT_TITLE, + logo, + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'platinum', + fields: { + name: { + label: i18n.MAPPING_FIELD_NAME, + validSourceFields: ['title', 'description'], + defaultSourceField: 'title', + defaultActionType: 'overwrite', + }, + description: { + label: i18n.MAPPING_FIELD_DESC, + validSourceFields: ['title', 'description'], + defaultSourceField: 'description', + defaultActionType: 'overwrite', + }, + comments: { + label: i18n.MAPPING_FIELD_COMMENTS, + validSourceFields: ['comments'], + defaultSourceField: 'comments', + defaultActionType: 'append', + }, + }, +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/index.ts new file mode 100644 index 0000000000000..0905bd29493e7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/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 getResilientActionType } from './resilient'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/logo.svg b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/logo.svg new file mode 100644 index 0000000000000..8560cf7e270c8 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx new file mode 100644 index 0000000000000..3de9b03b45f94 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx @@ -0,0 +1,73 @@ +/* + * 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 { lazy } from 'react'; +import { + ValidationResult, + ActionTypeModel, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../types'; +import { connectorConfiguration } from './config'; +import logo from './logo.svg'; +import { ResilientActionConnector, ResilientActionParams } from './types'; +import * as i18n from './translations'; + +const validateConnector = (action: ResilientActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + apiUrl: new Array(), + orgId: new Array(), + apiKeyId: new Array(), + apiKeySecret: new Array(), + }; + validationResult.errors = errors; + + if (!action.config.apiUrl) { + errors.apiUrl = [...errors.apiUrl, i18n.API_URL_REQUIRED]; + } + + // if (isUrlInvalid(action.config.apiUrl)) { + // errors.apiUrl = [...errors.apiUrl, i18n.API_URL_INVALID]; + // } + + if (!action.config.orgId) { + errors.orgId = [...errors.orgId, i18n.RESILIENT_PROJECT_KEY_LABEL]; + } + + if (!action.secrets.apiKeyId) { + errors.apiKeyId = [...errors.apiKeyId, i18n.RESILIENT_API_KEY_ID_REQUIRED]; + } + + if (!action.secrets.apiKeySecret) { + errors.apiKeySecret = [...errors.apiKeySecret, i18n.RESILIENT_API_KEY_SECRET_REQUIRED]; + } + + return validationResult; +}; + +export function getActionType(): ActionTypeModel { + return { + id: connectorConfiguration.id, + iconClass: logo, + selectMessage: i18n.RESILIENT_DESC, + actionTypeTitle: connectorConfiguration.name, + // minimumLicenseRequired: 'platinum', + validateConnector, + actionConnectorFields: lazy(() => import('./resilient_connectors')), + validateParams: (actionParams: ResilientActionParams): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + title: new Array(), + }; + validationResult.errors = errors; + if (actionParams.subActionParams && !actionParams.subActionParams.title?.length) { + errors.title.push(i18n.TITLE_REQUIRED); + } + return validationResult; + }, + actionParamsFields: lazy(() => import('./resilient_params')), + }; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx new file mode 100644 index 0000000000000..ff35729eeeb7a --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_connectors.tsx @@ -0,0 +1,197 @@ +/* + * 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, { useCallback, useEffect } from 'react'; + +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldPassword, + EuiSpacer, +} from '@elastic/eui'; + +import { isEmpty } from 'lodash'; +import { IErrorObject, ActionConnectorFieldsProps } from '../../../../types'; +import * as i18n from './translations'; +import { CasesConfigurationMapping, ResilientActionConnector } from './types'; +import { connectorConfiguration } from './config'; +import { FieldMapping } from './case_mappings/field_mapping'; + +export interface ConnectorFlyoutFormProps { + errors: IErrorObject; + action: T; + onChangeSecret: (key: string, value: string) => void; + onBlurSecret: (key: string) => void; + onChangeConfig: (key: string, value: string) => void; + onBlurConfig: (key: string) => void; +} + +const ServiceNowConnectorFields: React.FC> = ({ + action, + editActionSecrets, + editActionConfig, + errors, + // editActionProperty, +}) => { + // TODO: remove incidentConfiguration later, when Case IBM Resilient will move their fields to the level of action execution + const { apiUrl, incidentConfiguration } = action.config; + const mapping = incidentConfiguration ? incidentConfiguration.mapping : []; + + const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl != null; + + const { apiKeyId, apiKeySecret } = action.secrets; + + const isApiKeyIdInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId != null; + const isApiKeySecretInvalid: boolean = errors.apiKeySecret.length > 0 && apiKeySecret != null; + + // useEffect(() => { + // if (!action.consumer && editActionProperty) { + // editActionProperty('consumer', 'alerts'); + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, []); + + // TODO: remove this block later, when Case ServiceNow will move their fields to the level of action execution + if (/* action.consumer === 'case' && */ isEmpty(mapping)) { + editActionConfig('incidentConfiguration', { + mapping: createDefaultMapping(connectorConfiguration.fields as any), + }); + } + + const handleOnChangeActionConfig = useCallback( + (key: string, value: string) => editActionConfig(key, value), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const handleOnChangeSecretConfig = useCallback( + (key: string, value: string) => editActionSecrets(key, value), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const handleOnChangeMappingConfig = useCallback( + (newMapping: CasesConfigurationMapping[]) => + editActionConfig('incidentConfiguration', { + ...action.config.incidentConfiguration, + mapping: newMapping, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [action.config] + ); + + return ( + <> + + + + handleOnChangeActionConfig('apiUrl', evt.target.value)} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + /> + + + + + + + + handleOnChangeSecretConfig('apiKeyId', evt.target.value)} + onBlur={() => { + if (!apiKeyId) { + editActionSecrets('apiKeyId', ''); + } + }} + /> + + + + + + + + handleOnChangeSecretConfig('apiKeySecret', evt.target.value)} + onBlur={() => { + if (!apiKeySecret) { + editActionSecrets('apiKeySecret', ''); + } + }} + /> + + + + {incidentConfiguration && ( // TODO: remove this block later, when Case ServiceNow will move their fields to the level of action execution + <> + + + + + + + + )} + + ); +}; + +export const createDefaultMapping = (fields: Record): CasesConfigurationMapping[] => + Object.keys(fields).map( + (key) => + ({ + source: fields[key].defaultSourceField, + target: key, + actionType: fields[key].defaultActionType, + } as CasesConfigurationMapping) + ); + +// eslint-disable-next-line import/no-default-export +export { ServiceNowConnectorFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx new file mode 100644 index 0000000000000..16ea6a5f2cc64 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient_params.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useEffect } from 'react'; +import { EuiFormRow, EuiTextArea } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiFieldText } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; +import { EuiTitle } from '@elastic/eui'; +import { ActionParamsProps } from '../../../../types'; +import { AddMessageVariables } from '../../add_message_variables'; +import { ResilientActionParams } from './types'; + +const ResilientParamsFields: React.FunctionComponent> = ({ + actionParams, + editAction, + index, + errors, + messageVariables, +}) => { + const { title, description, comment, savedObjectId } = actionParams.subActionParams || {}; + + const editSubActionProperty = (key: string, value: {}) => { + const newProps = { ...actionParams.subActionParams, [key]: value }; + editAction('subActionParams', newProps, index); + }; + + useEffect(() => { + if (!actionParams.subAction) { + editAction('subAction', 'pushToService', index); + } + if (!savedObjectId && messageVariables?.find((variable) => variable === 'alertId')) { + editSubActionProperty('savedObjectId', '{{alertId}}'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [title, description, comment]); + + const onSelectMessageVariable = (paramsProperty: string, variable: string) => { + editSubActionProperty( + paramsProperty, + ((actionParams as any).subActionParams[paramsProperty] ?? '').concat(` {{${variable}}}`) + ); + }; + + return ( + + +

Incident

+
+ + 0 && title !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.titleFieldLabel', + { + defaultMessage: 'Name', + } + )} + labelAppend={ + onSelectMessageVariable('title', variable)} + paramsProperty="title" + /> + } + > + 0 && title !== undefined} + value={title || ''} + onChange={(e: React.ChangeEvent) => { + editSubActionProperty('title', e.target.value); + }} + onBlur={() => { + if (!title) { + editSubActionProperty('title', ''); + } + }} + /> + + + onSelectMessageVariable('description', variable) + } + paramsProperty="description" + /> + } + > + { + editSubActionProperty('description', e.target.value); + }} + onBlur={() => { + if (!description) { + editSubActionProperty('description', ''); + } + }} + /> + + + onSelectMessageVariable('comment', variable) + } + paramsProperty="comment" + /> + } + > + { + editSubActionProperty('comment', e.target.value); + }} + onBlur={() => { + if (!comment) { + editSubActionProperty('comment', ''); + } + }} + /> + +
+ ); +}; + +// eslint-disable-next-line import/no-default-export +export { ResilientParamsFields as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/translations.ts new file mode 100644 index 0000000000000..bccc919acc45e --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/translations.ts @@ -0,0 +1,112 @@ +/* + * 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 RESILIENT_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText', + { + defaultMessage: 'Push or update SIEM case data to a new issue in resilient', + } +); + +export const RESILIENT_TITLE = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.actionTypeTitle', + { + defaultMessage: 'IBM Resilient', + } +); + +export const RESILIENT_PROJECT_KEY_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.orgId', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_PROJECT_KEY_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredOrgIdTextField', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_API_KEY_ID_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.apiKeyId', + { + defaultMessage: 'API key id', + } +); + +export const RESILIENT_API_KEY_ID_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredApiKeyIdTextField', + { + defaultMessage: 'API key id is required', + } +); + +export const RESILIENT_API_KEY_SECRET_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.apiKeySecret', + { + defaultMessage: 'API key secret', + } +); + +export const RESILIENT_API_KEY_SECRET_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredApiKeySecretTextField', + { + defaultMessage: 'API key secret is required', + } +); + +export const MAPPING_FIELD_NAME = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.mappingFieldName', + { + defaultMessage: 'Name', + } +); + +export const MAPPING_FIELD_DESC = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.mappingFieldDescription', + { + defaultMessage: 'Description', + } +); + +export const MAPPING_FIELD_COMMENTS = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.mappingFieldComments', + { + defaultMessage: 'Comments', + } +); + +export const TITLE_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.common.requiredTitleTextField', + { + defaultMessage: 'Title is required.', + } +); + +export const API_URL_REQUIRED = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredApiUrlTextField', + { + defaultMessage: 'URL is required.', + } +); + +export const API_URL_INVALID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.invalidApiUrlTextField', + { + defaultMessage: 'URL is invalid.', + } +); + +export const API_URL_LABEL = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.resilient.apiUrlTextFieldLabel', + { + defaultMessage: 'URL', + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/types.ts new file mode 100644 index 0000000000000..db54ffe101580 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/types.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// to remove +import { CaseField, ThirdPartyField, ActionType } from '../../../../../../case/common/api'; + +import { ActionConnector } from '../../../../types'; + +export interface ResilientActionConnector extends ActionConnector { + config: ResilientConfig; + secrets: ResilientSecrets; +} + +export interface ResilientActionParams { + subAction: string; + subActionParams: { + savedObjectId: string; + title: string; + description: string; + comment: string; + externalId: string | null; + }; +} + +interface IncidentConfiguration { + mapping: CasesConfigurationMapping[]; +} + +interface ResilientConfig { + apiUrl: string; + orgId: string; + incidentConfiguration?: IncidentConfiguration; +} + +interface ResilientSecrets { + apiKeyId: string; + apiKeySecret: string; +} + +// to remove +export interface CasesConfigurationMapping { + source: CaseField; + target: ThirdPartyField; + actionType: ActionType; +}