[Alerting][Connectors] Refactor IBM Resilient: Generic Implementation (phase one) (#74357)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2020-09-11 10:24:46 +03:00 committed by GitHub
parent 177d67e8bc
commit 22b4e40ea0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 2660 additions and 1600 deletions

View file

@ -331,15 +331,17 @@ const result = await actionsClient.execute({
Kibana ships with a set of built-in action types:
| Type | Id | Description |
| ------------------------- | ------------- | ------------------------------------------------------------------ |
| [Server log](#server-log) | `.server-log` | Logs messages to the Kibana log using Kibana's logger |
| [Email](#email) | `.email` | Sends an email using SMTP |
| [Slack](#slack) | `.slack` | Posts a message to a slack channel |
| [Index](#index) | `.index` | Indexes document(s) into Elasticsearch |
| [Webhook](#webhook) | `.webhook` | Send a payload to a web service using HTTP POST or PUT |
| [PagerDuty](#pagerduty) | `.pagerduty` | Trigger, resolve, or acknowlege an incident to a PagerDuty service |
| [ServiceNow](#servicenow) | `.servicenow` | Create or update an incident to a ServiceNow instance |
| Type | Id | Description |
| ------------------------------- | ------------- | ------------------------------------------------------------------ |
| [Server log](#server-log) | `.server-log` | Logs messages to the Kibana log using Kibana's logger |
| [Email](#email) | `.email` | Sends an email using SMTP |
| [Slack](#slack) | `.slack` | Posts a message to a slack channel |
| [Index](#index) | `.index` | Indexes document(s) into Elasticsearch |
| [Webhook](#webhook) | `.webhook` | Send a payload to a web service using HTTP POST or PUT |
| [PagerDuty](#pagerduty) | `.pagerduty` | Trigger, resolve, or acknowlege an incident to a PagerDuty service |
| [ServiceNow](#servicenow) | `.servicenow` | Create or update an incident to a ServiceNow instance |
| [Jira](#jira) | `.jira` | Create or update an issue to a Jira instance |
| [IBM Resilient](#ibm-resilient) | `.resilient` | Create or update an incident to a IBM Resilient instance |
---
@ -561,8 +563,8 @@ The ServiceNow action uses the [V2 Table API](https://developer.servicenow.com/a
| Property | Description | Type |
| ------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| savedObjectId | The id of the saved object. | string |
| title | The title of the case. | string _(optional)_ |
| description | The description of the case. | string _(optional)_ |
| title | The title of the incident. | string _(optional)_ |
| description | The description of the incident. | string _(optional)_ |
| comment | A comment. | string _(optional)_ |
| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ |
| externalId | The id of the incident in ServiceNow. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ |
@ -601,16 +603,16 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla
#### `subActionParams (pushToService)`
| Property | Description | Type |
| ------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------- |
| savedObjectId | The id of the saved object | string |
| title | The title of the case | string _(optional)_ |
| description | The description of the case | string _(optional)_ |
| externalId | The id of the incident in Jira. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ |
| issueType | The id of the issue type in Jira. | string _(optional)_ |
| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ |
| labels | An array of labels. | string[] _(optional)_ |
| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ |
| Property | Description | Type |
| ------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------- |
| savedObjectId | The id of the saved object | string |
| title | The title of the issue | string _(optional)_ |
| description | The description of the issue | string _(optional)_ |
| externalId | The id of the issue in Jira. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ |
| issueType | The id of the issue type in Jira. | string _(optional)_ |
| priority | The name of the priority in Jira. Example: `Medium`. | string _(optional)_ |
| labels | An array of labels. | string[] _(optional)_ |
| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ |
#### `subActionParams (issueTypes)`
@ -628,10 +630,10 @@ ID: `.resilient`
### `config`
| Property | Description | Type |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
| apiUrl | IBM Resilient instance URL. | string |
| incidentConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in IBM Resilient and will be overwrite on each update. | object |
| Property | Description | Type |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| apiUrl | IBM Resilient instance URL. | string |
| incidentConfiguration | Optional property and specific to **Cases only**. If defined, the object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in IBM Resilient and will be overwrite on each update. | object |
### `secrets`
@ -652,10 +654,12 @@ ID: `.resilient`
| Property | Description | Type |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| savedObjectId | The id of the saved object | string |
| title | The title of the case | string _(optional)_ |
| description | The description of the case | string _(optional)_ |
| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ |
| title | The title of the incident | string _(optional)_ |
| description | The description of the incident | string _(optional)_ |
| comments | The comments of the incident. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ |
| externalId | The id of the incident in IBM Resilient. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ |
| incidentTypes | An array with the ids of IBM Resilient incident types. | number[] _(optional)_ |
| severityCode | IBM Resilient id of the severity code. | number _(optional)_ |
# Command Line Utility

View file

@ -1,93 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
ExternalServiceApi,
ExternalServiceParams,
PushToServiceResponse,
GetIncidentApiHandlerArgs,
HandshakeApiHandlerArgs,
PushToServiceApiHandlerArgs,
} from './types';
import { prepareFieldsForTransformation, transformFields, transformComments } from './utils';
const handshakeHandler = async ({
externalService,
mapping,
params,
}: HandshakeApiHandlerArgs) => {};
const getIncidentHandler = async ({
externalService,
mapping,
params,
}: GetIncidentApiHandlerArgs) => {};
const pushToServiceHandler = async ({
externalService,
mapping,
params,
}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => {
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);
}
const fields = prepareFieldsForTransformation({
externalCase: params.externalCase,
mapping,
defaultPipes,
});
const incident = transformFields({
params,
fields,
currentIncident,
});
if (updateIncident) {
res = await externalService.updateIncident({ incidentId: externalId, 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']);
res.comments = [];
for (const currentComment of commentsTransformed) {
const comment = await externalService.createComment({
incidentId: res.id,
comment: currentComment,
field: mapping.get('comments')?.target ?? 'comments',
});
res.comments = [
...(res.comments ?? []),
{
commentId: comment.commentId,
pushedDate: comment.pushedDate,
},
];
}
}
return res;
};
export const api: ExternalServiceApi = {
handshake: handshakeHandler,
pushToService: pushToServiceHandler,
getIncident: getIncidentHandler,
};

View file

@ -1,43 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { 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 UserSchema = schema.object({
fullName: schema.nullable(schema.string()),
username: schema.nullable(schema.string()),
});
export const EntityInformation = {
createdAt: schema.nullable(schema.string()),
createdBy: schema.nullable(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(),
...EntityInformation,
});

View file

@ -1,48 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { TypeOf } from '@kbn/config-schema';
import {
IncidentConfigurationSchema,
MapRecordSchema,
CommentSchema,
EntityInformationSchema,
} from './common_schema';
export interface CreateCommentRequest {
[key: string]: string;
}
export type IncidentConfiguration = TypeOf<typeof IncidentConfigurationSchema>;
export type MapRecord = TypeOf<typeof MapRecordSchema>;
export type Comment = TypeOf<typeof CommentSchema>;
export type EntityInformation = TypeOf<typeof EntityInformationSchema>;
export interface ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}
export interface PipedField {
key: string;
value: string;
actionType: string;
pipes: string[];
}
export interface TransformFieldsArgs<P, S> {
params: P;
fields: PipedField[];
currentIncident?: S;
}
export interface TransformerArgs {
value: string;
date?: string;
user?: string;
previousValue?: string;
}

View file

@ -18,36 +18,18 @@ export const MapRecordSchema = schema.object({
actionType: MappingActionType,
});
export const CaseConfigurationSchema = schema.object({
export const IncidentConfigurationSchema = schema.object({
mapping: schema.arrayOf(MapRecordSchema),
});
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),
casesConfiguration: CaseConfigurationSchema,
};
export const ExternalIncidentServiceConfigurationSchema = schema.object(
ExternalIncidentServiceConfiguration
);
export const ExternalIncidentServiceSecretConfiguration = {
password: schema.string(),
username: schema.string(),
};
export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
ExternalIncidentServiceSecretConfiguration
);
export const UserSchema = schema.object({
fullName: schema.nullable(schema.string()),
username: schema.nullable(schema.string()),
});
const EntityInformation = {
createdAt: schema.string(),
createdBy: UserSchema,
export const EntityInformation = {
createdAt: schema.nullable(schema.string()),
createdBy: schema.nullable(UserSchema),
updatedAt: schema.nullable(schema.string()),
updatedBy: schema.nullable(UserSchema),
};
@ -59,40 +41,3 @@ export const CommentSchema = schema.object({
comment: schema.string(),
...EntityInformation,
});
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()),
comments: schema.nullable(schema.arrayOf(CommentSchema)),
externalId: schema.nullable(schema.string()),
...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,
}),
]);

View file

@ -4,74 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
// This will have to remain `any` until we can extend connectors with generics
// and circular dependencies eliminated.
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TypeOf } from '@kbn/config-schema';
import { Logger } from '../../../../../../src/core/server';
import {
ExternalIncidentServiceConfigurationSchema,
ExternalIncidentServiceSecretConfigurationSchema,
ExecutorParamsSchema,
CaseConfigurationSchema,
IncidentConfigurationSchema,
MapRecordSchema,
CommentSchema,
ExecutorSubActionPushParamsSchema,
ExecutorSubActionGetIncidentParamsSchema,
ExecutorSubActionHandshakeParamsSchema,
EntityInformationSchema,
} from './schema';
import { LicenseType } from '../../../../../legacy/common/constants';
export interface AnyParams {
[index: string]: string | number | object | undefined | null;
}
export type ExternalIncidentServiceConfiguration = TypeOf<
typeof ExternalIncidentServiceConfigurationSchema
>;
export type ExternalIncidentServiceSecretConfiguration = TypeOf<
typeof ExternalIncidentServiceSecretConfigurationSchema
>;
export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>;
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
export type ExecutorSubActionGetIncidentParams = TypeOf<
typeof ExecutorSubActionGetIncidentParamsSchema
>;
export type ExecutorSubActionHandshakeParams = TypeOf<
typeof ExecutorSubActionHandshakeParamsSchema
>;
export type CaseConfiguration = TypeOf<typeof CaseConfigurationSchema>;
export type IncidentConfiguration = TypeOf<typeof IncidentConfigurationSchema>;
export type MapRecord = TypeOf<typeof MapRecordSchema>;
export type Comment = TypeOf<typeof CommentSchema>;
export interface ExternalServiceConfiguration {
id: string;
name: string;
minimumLicenseRequired: LicenseType;
}
export interface ExternalServiceCredentials {
config: Record<string, any>;
secrets: Record<string, any>;
}
export interface ExternalServiceValidation {
config: (configurationUtilities: any, configObject: any) => void;
secrets: (configurationUtilities: any, secrets: any) => void;
}
export interface ExternalServiceIncidentResponse {
id: string;
title: string;
url: string;
pushedDate: string;
}
export type EntityInformation = TypeOf<typeof EntityInformationSchema>;
export interface ExternalServiceCommentResponse {
commentId: string;
@ -79,69 +23,6 @@ export interface ExternalServiceCommentResponse {
externalCommentId?: string;
}
export interface ExternalServiceParams {
[index: string]: any;
}
export interface ExternalService {
getIncident: (id: string) => Promise<any>;
createIncident: (params: ExternalServiceParams) => Promise<ExternalServiceIncidentResponse>;
updateIncident: (params: ExternalServiceParams) => Promise<ExternalServiceIncidentResponse>;
createComment: (params: ExternalServiceParams) => Promise<ExternalServiceCommentResponse>;
}
export interface PushToServiceApiParams extends ExecutorSubActionPushParams {
externalCase: Record<string, any>;
}
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
mapping: Map<string, any>;
}
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: PushToServiceApiParams;
}
export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: ExecutorSubActionGetIncidentParams;
}
export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: ExecutorSubActionHandshakeParams;
}
export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
comments?: ExternalServiceCommentResponse[];
}
export interface ExternalServiceApi {
handshake: (args: HandshakeApiHandlerArgs) => Promise<void>;
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<void>;
}
export interface CreateExternalServiceBasicArgs {
api: ExternalServiceApi;
createExternalService: (
credentials: ExternalServiceCredentials,
logger: Logger,
proxySettings?: any
) => ExternalService;
logger: Logger;
}
export interface CreateExternalServiceArgs extends CreateExternalServiceBasicArgs {
config: ExternalServiceConfiguration;
validate: ExternalServiceValidation;
validationSchema: { config: any; secrets: any };
}
export interface CreateActionTypeArgs {
configurationUtilities: any;
executor?: any;
}
export interface PipedField {
key: string;
value: string;
@ -149,16 +30,10 @@ export interface PipedField {
pipes: string[];
}
export interface PrepareFieldsForTransformArgs {
externalCase: Record<string, any>;
mapping: Map<string, MapRecord>;
defaultPipes?: string[];
}
export interface TransformFieldsArgs {
params: PushToServiceApiParams;
export interface TransformFieldsArgs<P, S> {
params: P;
fields: PipedField[];
currentIncident?: ExternalServiceParams;
currentIncident?: S;
}
export interface TransformerArgs {
@ -167,3 +42,13 @@ export interface TransformerArgs {
user?: string;
previousValue?: string;
}
export interface AnyParams {
[index: string]: string | number | object | undefined | null;
}
export interface PrepareFieldsForTransformArgs {
externalCase: Record<string, string>;
mapping: Map<string, MapRecord>;
defaultPipes?: string[];
}

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
normalizeMapping,
buildMap,
@ -14,7 +16,23 @@ import {
} from './utils';
import { SUPPORTED_SOURCE_FIELDS } from './constants';
import { Comment, MapRecord, PushToServiceApiParams } from './types';
import { Comment, MapRecord } from './types';
interface Entity {
createdAt: string | null;
createdBy: { fullName: string; username: string } | null;
updatedAt: string | null;
updatedBy: { fullName: string; username: string } | null;
}
interface PushToServiceApiParams extends Entity {
savedObjectId: string;
title: string;
description: string | null;
externalId: string | null;
externalObject: Record<string, any>;
comments: Comment[];
}
const mapping: MapRecord[] = [
{ source: 'title', target: 'short_description', actionType: 'overwrite' },
@ -22,7 +40,6 @@ const mapping: MapRecord[] = [
{ source: 'comments', target: 'comments', actionType: 'append' },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const finalMapping: Map<string, any> = new Map();
finalMapping.set('title', {
@ -61,7 +78,7 @@ const fullParams: PushToServiceApiParams = {
updatedAt: null,
updatedBy: null,
externalId: null,
externalCase: {
externalObject: {
short_description: 'a title',
description: 'a description',
},
@ -154,7 +171,7 @@ describe('mapParams', () => {
describe('prepareFieldsForTransformation', () => {
test('prepare fields with defaults', () => {
const res = prepareFieldsForTransformation({
externalCase: fullParams.externalCase,
externalCase: fullParams.externalObject,
mapping: finalMapping,
});
expect(res).toEqual([
@ -175,7 +192,7 @@ describe('prepareFieldsForTransformation', () => {
test('prepare fields with default pipes', () => {
const res = prepareFieldsForTransformation({
externalCase: fullParams.externalCase,
externalCase: fullParams.externalObject,
mapping: finalMapping,
defaultPipes: ['myTestPipe'],
});
@ -199,11 +216,15 @@ describe('prepareFieldsForTransformation', () => {
describe('transformFields', () => {
test('transform fields for creation correctly', () => {
const fields = prepareFieldsForTransformation({
externalCase: fullParams.externalCase,
externalCase: fullParams.externalObject,
mapping: finalMapping,
});
const res = transformFields({
const res = transformFields<
PushToServiceApiParams,
{},
{ short_description: string; description: string }
>({
params: fullParams,
fields,
});
@ -216,12 +237,16 @@ describe('transformFields', () => {
test('transform fields for update correctly', () => {
const fields = prepareFieldsForTransformation({
externalCase: fullParams.externalCase,
externalCase: fullParams.externalObject,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
const res = transformFields({
const res = transformFields<
PushToServiceApiParams,
{},
{ short_description: string; description: string }
>({
params: {
...fullParams,
updatedAt: '2020-03-15T08:34:53.450Z',
@ -245,12 +270,16 @@ describe('transformFields', () => {
test('add newline character to description', () => {
const fields = prepareFieldsForTransformation({
externalCase: fullParams.externalCase,
externalCase: fullParams.externalObject,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
const res = transformFields({
const res = transformFields<
PushToServiceApiParams,
{},
{ short_description: string; description: string }
>({
params: fullParams,
fields,
currentIncident: {
@ -263,11 +292,15 @@ describe('transformFields', () => {
test('append username if fullname is undefined when create', () => {
const fields = prepareFieldsForTransformation({
externalCase: fullParams.externalCase,
externalCase: fullParams.externalObject,
mapping: finalMapping,
});
const res = transformFields({
const res = transformFields<
PushToServiceApiParams,
{},
{ short_description: string; description: string }
>({
params: {
...fullParams,
createdBy: { fullName: '', username: 'elastic' },
@ -283,12 +316,16 @@ describe('transformFields', () => {
test('append username if fullname is undefined when update', () => {
const fields = prepareFieldsForTransformation({
externalCase: fullParams.externalCase,
externalCase: fullParams.externalObject,
mapping: finalMapping,
defaultPipes: ['informationUpdated'],
});
const res = transformFields({
const res = transformFields<
PushToServiceApiParams,
{},
{ short_description: string; description: string }
>({
params: {
...fullParams,
updatedAt: '2020-03-15T08:34:53.450Z',

View file

@ -4,30 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { curry, flow, get } from 'lodash';
import { schema } from '@kbn/config-schema';
import { ActionTypeExecutorOptions, ActionTypeExecutorResult, ActionType } from '../../types';
import { ExecutorParamsSchema } from './schema';
import {
ExternalIncidentServiceConfiguration,
ExternalIncidentServiceSecretConfiguration,
} from './types';
import { flow, get } from 'lodash';
import {
CreateExternalServiceArgs,
CreateActionTypeArgs,
ExecutorParams,
MapRecord,
AnyParams,
CreateExternalServiceBasicArgs,
PrepareFieldsForTransformArgs,
PipedField,
TransformFieldsArgs,
Comment,
ExecutorSubActionPushParams,
PushToServiceResponse,
EntityInformation,
PipedField,
AnyParams,
PrepareFieldsForTransformArgs,
} from './types';
import { transformers } from './transformers';
@ -61,92 +47,6 @@ export const mapParams = <T extends {}>(params: T, mapping: Map<string, MapRecor
}, {});
};
export const createConnectorExecutor = ({
api,
createExternalService,
logger,
}: CreateExternalServiceBasicArgs) => async (
execOptions: ActionTypeExecutorOptions<
ExternalIncidentServiceConfiguration,
ExternalIncidentServiceSecretConfiguration,
ExecutorParams
>
): Promise<ActionTypeExecutorResult<PushToServiceResponse | {}>> => {
const { actionId, config, params, secrets } = execOptions;
const { subAction, subActionParams } = params;
let data = {};
const res: ActionTypeExecutorResult<void> = {
status: 'ok',
actionId,
};
const externalService = createExternalService(
{
config,
secrets,
},
logger,
execOptions.proxySettings
);
if (!api[subAction]) {
throw new Error('[Action][ExternalService] Unsupported subAction type.');
}
if (subAction !== 'pushToService') {
throw new Error('[Action][ExternalService] subAction not implemented.');
}
if (subAction === 'pushToService') {
const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
const { comments, externalId, ...restParams } = pushToServiceParams;
const mapping = buildMap(config.casesConfiguration.mapping);
const externalCase = mapParams<ExecutorSubActionPushParams>(
restParams as ExecutorSubActionPushParams,
mapping
);
data = await api.pushToService({
externalService,
mapping,
params: { ...pushToServiceParams, externalCase },
});
}
return {
...res,
data,
};
};
export const createConnector = ({
api,
config,
validate,
createExternalService,
validationSchema,
logger,
}: CreateExternalServiceArgs) => {
return ({
configurationUtilities,
executor = createConnectorExecutor({ api, createExternalService, logger }),
}: CreateActionTypeArgs): ActionType => ({
...config,
validate: {
config: schema.object(validationSchema.config, {
validate: curry(validate.config)(configurationUtilities),
}),
secrets: schema.object(validationSchema.secrets, {
validate: curry(validate.secrets)(configurationUtilities),
}),
params: ExecutorParamsSchema,
},
executor,
});
};
export const prepareFieldsForTransformation = ({
externalCase,
mapping,
@ -165,11 +65,15 @@ export const prepareFieldsForTransformation = ({
});
};
export const transformFields = ({
export const transformFields = <
P extends EntityInformation,
S extends Record<string, unknown>,
R extends {}
>({
params,
fields,
currentIncident,
}: TransformFieldsArgs): Record<string, string> => {
}: TransformFieldsArgs<P, S>): R => {
return fields.reduce((prev, cur) => {
const transform = flow(...cur.pipes.map((p) => transformers[p]));
return {
@ -177,18 +81,11 @@ export const transformFields = ({
[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) ?? '',
user: getEntity(params),
previousValue: currentIncident ? currentIncident[cur.key] : '',
}).value,
};
}, {});
}, {} as R);
};
export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => {
@ -197,18 +94,18 @@ export const transformComments = (comments: Comment[], pipes: string[]): Comment
comment: flow(...pipes.map((p) => transformers[p]))({
value: c.comment,
date: c.updatedAt ?? c.createdAt,
user:
(c.updatedBy != null
? c.updatedBy.fullName
? c.updatedBy.fullName
: c.updatedBy.username
: c.createdBy.fullName
? c.createdBy.fullName
: c.createdBy.username) ?? '',
user: getEntity(c),
}).value,
}));
};
export const getErrorMessage = (connector: string, msg: string) => {
return `[Action][${connector}]: ${msg}`;
};
export const getEntity = (entity: EntityInformation): string =>
(entity.updatedBy != null
? entity.updatedBy.fullName
? entity.updatedBy.fullName
: entity.updatedBy.username
: entity.createdBy != null
? entity.createdBy.fullName
? entity.createdBy.fullName
: entity.createdBy.username
: '') ?? '';

View file

@ -1,35 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty } from 'lodash';
import { ActionsConfigurationUtilities } from '../../actions_config';
import {
ExternalIncidentServiceConfiguration,
ExternalIncidentServiceSecretConfiguration,
} from './types';
import * as i18n from './translations';
export const validateCommonConfig = (
configurationUtilities: ActionsConfigurationUtilities,
configObject: ExternalIncidentServiceConfiguration
) => {
try {
if (isEmpty(configObject.casesConfiguration.mapping)) {
return i18n.MAPPING_EMPTY;
}
configurationUtilities.ensureUriAllowed(configObject.apiUrl);
} catch (allowListError) {
return i18n.WHITE_LISTED_ERROR(allowListError.message);
}
};
export const validateCommonSecrets = (
configurationUtilities: ActionsConfigurationUtilities,
secrets: ExternalIncidentServiceSecretConfiguration
) => {};

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { flow } from 'lodash';
import {
ExternalServiceParams,
PushToServiceApiHandlerArgs,
@ -15,14 +14,11 @@ import {
GetFieldsByIssueTypeHandlerArgs,
GetIssueTypesHandlerArgs,
PushToServiceApiParams,
PushToServiceResponse,
} from './types';
// TODO: to remove, need to support Case
import { transformers } from '../case/transformers';
import { TransformFieldsArgs, Comment, EntityInformation } from '../case/common_types';
import { PushToServiceResponse } from './types';
import { prepareFieldsForTransformation } from '../case/utils';
import { prepareFieldsForTransformation, transformFields, transformComments } from '../case/utils';
const handshakeHandler = async ({
externalService,
@ -81,7 +77,7 @@ const pushToServiceHandler = async ({
defaultPipes,
});
incident = transformFields({
incident = transformFields<PushToServiceApiParams, ExternalServiceParams, Incident>({
params,
fields,
currentIncident,
@ -132,47 +128,6 @@ const pushToServiceHandler = async ({
return res;
};
export const transformFields = ({
params,
fields,
currentIncident,
}: TransformFieldsArgs<PushToServiceApiParams, ExternalServiceParams>): Incident => {
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: getEntity(params),
previousValue: currentIncident ? currentIncident[cur.key] : '',
}).value,
};
}, {} as Incident);
};
export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => {
return comments.map((c) => ({
...c,
comment: flow(...pipes.map((p) => transformers[p]))({
value: c.comment,
date: c.updatedAt ?? c.createdAt,
user: getEntity(c),
}).value,
}));
};
export const getEntity = (entity: EntityInformation): string =>
(entity.updatedBy != null
? entity.updatedBy.fullName
? entity.updatedBy.fullName
: entity.updatedBy.username
: entity.createdBy != null
? entity.createdBy.fullName
? entity.createdBy.fullName
: entity.createdBy.username
: '') ?? '';
export const api: ExternalServiceApi = {
handshake: handshakeHandler,
pushToService: pushToServiceHandler,

View file

@ -6,7 +6,7 @@
import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
import { MapRecord } from '../case/common_types';
import { MapRecord } from '../case/types';
const createMock = (): jest.Mocked<ExternalService> => {
const service = {

View file

@ -5,11 +5,7 @@
*/
import { schema } from '@kbn/config-schema';
import {
CommentSchema,
EntityInformation,
IncidentConfigurationSchema,
} from '../case/common_schema';
import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from '../case/schema';
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),

View file

@ -19,8 +19,8 @@ import {
ExecutorSubActionGetFieldsByIssueTypeParamsSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { IncidentConfigurationSchema } from '../case/common_schema';
import { Comment } from '../case/common_types';
import { IncidentConfigurationSchema } from '../case/schema';
import { Comment } from '../case/types';
import { Logger } from '../../../../../../src/core/server';
export type JiraPublicConfigurationType = TypeOf<typeof ExternalIncidentServiceConfigurationSchema>;

View file

@ -4,9 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { api } from '../case/api';
import { Logger } from '../../../../../../src/core/server';
import { api } from './api';
import { externalServiceMock, mapping, apiParams } from './mocks';
import { ExternalService } from '../case/types';
import { ExternalService } from './types';
let mockedLogger: jest.Mocked<Logger>;
describe('api', () => {
let externalService: jest.Mocked<ExternalService>;
@ -23,7 +26,12 @@ describe('api', () => {
describe('create incident', () => {
test('it creates an incident', async () => {
const params = { ...apiParams, externalId: null };
const res = await api.pushToService({ externalService, mapping, params });
const res = await api.pushToService({
externalService,
mapping,
params,
logger: mockedLogger,
});
expect(res).toEqual({
id: '1',
@ -45,7 +53,12 @@ describe('api', () => {
test('it creates an incident without comments', async () => {
const params = { ...apiParams, externalId: null, comments: [] };
const res = await api.pushToService({ externalService, mapping, params });
const res = await api.pushToService({
externalService,
mapping,
params,
logger: mockedLogger,
});
expect(res).toEqual({
id: '1',
@ -57,7 +70,7 @@ describe('api', () => {
test('it calls createIncident correctly', async () => {
const params = { ...apiParams, externalId: null };
await api.pushToService({ externalService, mapping, params });
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
expect(externalService.createIncident).toHaveBeenCalledWith({
incident: {
@ -71,7 +84,7 @@ describe('api', () => {
test('it calls createComment correctly', async () => {
const params = { ...apiParams, externalId: null };
await api.pushToService({ externalService, mapping, params });
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: '1',
@ -89,7 +102,6 @@ describe('api', () => {
username: 'elastic',
},
},
field: 'comments',
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
@ -108,14 +120,18 @@ describe('api', () => {
username: 'elastic',
},
},
field: 'comments',
});
});
});
describe('update incident', () => {
test('it updates an incident', async () => {
const res = await api.pushToService({ externalService, mapping, params: apiParams });
const res = await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(res).toEqual({
id: '1',
@ -137,7 +153,12 @@ describe('api', () => {
test('it updates an incident without comments', async () => {
const params = { ...apiParams, comments: [] };
const res = await api.pushToService({ externalService, mapping, params });
const res = await api.pushToService({
externalService,
mapping,
params,
logger: mockedLogger,
});
expect(res).toEqual({
id: '1',
@ -149,7 +170,7 @@ describe('api', () => {
test('it calls updateIncident correctly', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, mapping, params });
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
@ -164,7 +185,7 @@ describe('api', () => {
test('it calls createComment correctly', async () => {
const params = { ...apiParams };
await api.pushToService({ externalService, mapping, params });
await api.pushToService({ externalService, mapping, params, logger: mockedLogger });
expect(externalService.createComment).toHaveBeenCalledTimes(2);
expect(externalService.createComment).toHaveBeenNthCalledWith(1, {
incidentId: '1',
@ -182,7 +203,6 @@ describe('api', () => {
username: 'elastic',
},
},
field: 'comments',
});
expect(externalService.createComment).toHaveBeenNthCalledWith(2, {
@ -201,11 +221,52 @@ describe('api', () => {
username: 'elastic',
},
},
field: 'comments',
});
});
});
describe('incidentTypes', () => {
test('it returns the incident types correctly', async () => {
const res = await api.incidentTypes({
externalService,
params: {},
});
expect(res).toEqual([
{
id: 17,
name: 'Communication error (fax; email)',
},
{
id: 1001,
name: 'Custom type',
},
]);
});
});
describe('severity', () => {
test('it returns the severity correctly', async () => {
const res = await api.severity({
externalService,
params: { id: '10006' },
});
expect(res).toEqual([
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
]);
});
});
describe('mapping variations', () => {
test('overwrite & append', async () => {
mapping.set('title', {
@ -228,7 +289,12 @@ describe('api', () => {
actionType: 'overwrite',
});
await api.pushToService({ externalService, mapping, params: apiParams });
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@ -260,7 +326,12 @@ describe('api', () => {
actionType: 'nothing',
});
await api.pushToService({ externalService, mapping, params: apiParams });
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@ -291,7 +362,12 @@ describe('api', () => {
actionType: 'append',
});
await api.pushToService({ externalService, mapping, params: apiParams });
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@ -324,7 +400,12 @@ describe('api', () => {
actionType: 'nothing',
});
await api.pushToService({ externalService, mapping, params: apiParams });
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {},
@ -352,7 +433,12 @@ describe('api', () => {
actionType: 'overwrite',
});
await api.pushToService({ externalService, mapping, params: apiParams });
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@ -382,7 +468,12 @@ describe('api', () => {
actionType: 'overwrite',
});
await api.pushToService({ externalService, mapping, params: apiParams });
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@ -414,7 +505,12 @@ describe('api', () => {
actionType: 'nothing',
});
await api.pushToService({ externalService, mapping, params: apiParams });
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@ -445,7 +541,12 @@ describe('api', () => {
actionType: 'append',
});
await api.pushToService({ externalService, mapping, params: apiParams });
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@ -478,7 +579,12 @@ describe('api', () => {
actionType: 'append',
});
await api.pushToService({ externalService, mapping, params: apiParams });
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.updateIncident).toHaveBeenCalledWith({
incidentId: 'incident-3',
incident: {
@ -509,7 +615,12 @@ describe('api', () => {
actionType: 'overwrite',
});
await api.pushToService({ externalService, mapping, params: apiParams });
await api.pushToService({
externalService,
mapping,
params: apiParams,
logger: mockedLogger,
});
expect(externalService.createComment).not.toHaveBeenCalled();
});
});

View file

@ -4,4 +4,129 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { api } from '../case/api';
import {
ExternalServiceParams,
PushToServiceApiHandlerArgs,
HandshakeApiHandlerArgs,
GetIncidentApiHandlerArgs,
ExternalServiceApi,
Incident,
GetIncidentTypesHandlerArgs,
GetSeverityHandlerArgs,
PushToServiceApiParams,
PushToServiceResponse,
} from './types';
// TODO: to remove, need to support Case
import { transformFields, prepareFieldsForTransformation, transformComments } from '../case/utils';
const handshakeHandler = async ({
externalService,
mapping,
params,
}: HandshakeApiHandlerArgs) => {};
const getIncidentHandler = async ({
externalService,
mapping,
params,
}: GetIncidentApiHandlerArgs) => {};
const getIncidentTypesHandler = async ({ externalService }: GetIncidentTypesHandlerArgs) => {
const res = await externalService.getIncidentTypes();
return res;
};
const getSeverityHandler = async ({ externalService }: GetSeverityHandlerArgs) => {
const res = await externalService.getSeverity();
return res;
};
const pushToServiceHandler = async ({
externalService,
mapping,
params,
logger,
}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => {
const { externalId, comments } = params;
const updateIncident = externalId ? true : false;
const defaultPipes = updateIncident ? ['informationUpdated'] : ['informationCreated'];
let currentIncident: ExternalServiceParams | undefined;
let res: PushToServiceResponse;
if (externalId) {
try {
currentIncident = await externalService.getIncident(externalId);
} catch (ex) {
logger.debug(
`Retrieving Incident by id ${externalId} from IBM Resilient was failed with exception: ${ex}`
);
}
}
let incident: 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<PushToServiceApiParams, ExternalServiceParams, Incident>({
params,
fields,
currentIncident,
});
} else {
const { title, description, incidentTypes, severityCode } = params;
incident = { name: title, description, incidentTypes, severityCode };
}
if (externalId != null) {
res = await externalService.updateIncident({
incidentId: externalId,
incident,
});
} else {
res = await externalService.createIncident({
incident: {
...incident,
},
});
}
if (comments && Array.isArray(comments) && comments.length > 0) {
if (mapping && mapping.get('comments')?.actionType === 'nothing') {
return res;
}
const commentsTransformed = mapping
? transformComments(comments, ['informationAdded'])
: comments;
res.comments = [];
for (const currentComment of commentsTransformed) {
const comment = await externalService.createComment({
incidentId: res.id,
comment: currentComment,
});
res.comments = [
...(res.comments ?? []),
{
commentId: comment.commentId,
pushedDate: comment.pushedDate,
},
];
}
}
return res;
};
export const api: ExternalServiceApi = {
handshake: handshakeHandler,
pushToService: pushToServiceHandler,
getIncident: getIncidentHandler,
incidentTypes: getIncidentTypesHandler,
severity: getSeverityHandler,
};

View file

@ -1,14 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ExternalServiceConfiguration } from '../case/types';
import * as i18n from './translations';
export const config: ExternalServiceConfiguration = {
id: '.resilient',
name: i18n.NAME,
minimumLicenseRequired: 'platinum',
};

View file

@ -4,33 +4,139 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Logger } from '../../../../../../src/core/server';
import { createConnector } from '../case/utils';
import { curry } from 'lodash';
import { schema } from '@kbn/config-schema';
import { api } from './api';
import { config } from './config';
import { validate } from './validators';
import { createExternalService } from './service';
import { ResilientSecretConfiguration, ResilientPublicConfiguration } from './schema';
import {
ExternalIncidentServiceConfiguration,
ExternalIncidentServiceSecretConfiguration,
ExecutorParamsSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { ActionType } from '../../types';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
import { createExternalService } from './service';
import { api } from './api';
import {
ExecutorParams,
ExecutorSubActionPushParams,
ResilientPublicConfigurationType,
ResilientSecretConfigurationType,
ResilientExecutorResultData,
ExecutorSubActionGetIncidentTypesParams,
ExecutorSubActionGetSeverityParams,
} from './types';
import * as i18n from './translations';
import { Logger } from '../../../../../../src/core/server';
export function getActionType({
logger,
configurationUtilities,
}: {
// TODO: to remove, need to support Case
import { buildMap, mapParams } from '../case/utils';
interface GetActionTypeParams {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
}): ActionType {
return createConnector({
api,
config,
validate,
createExternalService,
validationSchema: {
config: ResilientPublicConfiguration,
secrets: ResilientSecretConfiguration,
}
const supportedSubActions: string[] = ['pushToService', 'incidentTypes', 'severity'];
// action type definition
export function getActionType(
params: GetActionTypeParams
): ActionType<
ResilientPublicConfigurationType,
ResilientSecretConfigurationType,
ExecutorParams,
ResilientExecutorResultData | {}
> {
const { logger, configurationUtilities } = params;
return {
id: '.resilient',
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<
ResilientPublicConfigurationType,
ResilientSecretConfigurationType,
ExecutorParams
>
): Promise<ActionTypeExecutorResult<ResilientExecutorResultData | {}>> {
const { actionId, config, params, secrets } = execOptions;
const { subAction, subActionParams } = params as ExecutorParams;
let data: ResilientExecutorResultData | null = null;
const externalService = createExternalService(
{
config,
secrets,
},
logger,
})({ configurationUtilities });
execOptions.proxySettings
);
if (!api[subAction]) {
const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (!supportedSubActions.includes(subAction)) {
const errorMessage = `[Action][ExternalService] subAction ${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<ExecutorSubActionPushParams>(restParams as ExecutorSubActionPushParams, mapping)
: {};
data = await api.pushToService({
externalService,
mapping,
params: { ...pushToServiceParams, externalObject },
logger,
});
logger.debug(`response push to service for incident id: ${data.id}`);
}
if (subAction === 'incidentTypes') {
const incidentTypesParams = subActionParams as ExecutorSubActionGetIncidentTypesParams;
data = await api.incidentTypes({
externalService,
params: incidentTypesParams,
});
}
if (subAction === 'severity') {
const severityParams = subActionParams as ExecutorSubActionGetSeverityParams;
data = await api.severity({
externalService,
params: severityParams,
});
}
return { status: 'ok', data: data ?? {}, actionId };
}

View file

@ -4,12 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
ExternalService,
PushToServiceApiParams,
ExecutorSubActionPushParams,
MapRecord,
} from '../case/types';
import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
import { MapRecord } from '../case/types';
const createMock = (): jest.Mocked<ExternalService> => {
const service = {
@ -40,6 +37,25 @@ const createMock = (): jest.Mocked<ExternalService> => {
})
),
createComment: jest.fn(),
findIncidents: jest.fn(),
getIncidentTypes: jest.fn().mockImplementation(() => [
{ id: 17, name: 'Communication error (fax; email)' },
{ id: 1001, name: 'Custom type' },
]),
getSeverity: jest.fn().mockImplementation(() => [
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
]),
};
service.createComment.mockImplementationOnce(() =>
@ -96,6 +112,8 @@ const executorParams: ExecutorSubActionPushParams = {
updatedBy: { fullName: 'Elastic User', username: 'elastic' },
title: 'Incident title',
description: 'Incident description',
incidentTypes: [1001],
severityCode: 6,
comments: [
{
commentId: 'case-comment-1',
@ -118,7 +136,58 @@ const executorParams: ExecutorSubActionPushParams = {
const apiParams: PushToServiceApiParams = {
...executorParams,
externalCase: { name: 'Incident title', description: 'Incident description' },
externalObject: { name: 'Incident title', description: 'Incident description' },
};
export { externalServiceMock, mapping, executorParams, apiParams };
const incidentTypes = [
{
value: 17,
label: 'Communication error (fax; email)',
enabled: true,
properties: null,
uuid: '4a8d22f7-d89e-4403-85c7-2bafe3b7f2ae',
hidden: false,
default: false,
},
{
value: 1001,
label: 'Custom type',
enabled: true,
properties: null,
uuid: '3b51c8c2-9758-48f8-b013-bd141f1d2ec9',
hidden: false,
default: false,
},
];
const severity = [
{
value: 4,
label: 'Low',
enabled: true,
properties: null,
uuid: '97cae239-963d-4e36-be34-07e47ef2cc86',
hidden: false,
default: true,
},
{
value: 5,
label: 'Medium',
enabled: true,
properties: null,
uuid: 'c2c354c9-6d1e-4a48-82e5-bd5dc5068339',
hidden: false,
default: false,
},
{
value: 6,
label: 'High',
enabled: true,
properties: null,
uuid: '93e5c99c-563b-48b9-80a3-9572307622d8',
hidden: false,
default: false,
},
];
export { externalServiceMock, mapping, executorParams, apiParams, incidentTypes, severity };

View file

@ -5,18 +5,77 @@
*/
import { schema } from '@kbn/config-schema';
import { ExternalIncidentServiceConfiguration } from '../case/schema';
import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from '../case/schema';
export const ResilientPublicConfiguration = {
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),
orgId: schema.string(),
...ExternalIncidentServiceConfiguration,
// TODO: to remove - set it optional for the current stage to support Case implementation
incidentConfiguration: schema.nullable(IncidentConfigurationSchema),
isCaseOwned: schema.nullable(schema.boolean()),
};
export const ResilientPublicConfigurationSchema = schema.object(ResilientPublicConfiguration);
export const ExternalIncidentServiceConfigurationSchema = schema.object(
ExternalIncidentServiceConfiguration
);
export const ResilientSecretConfiguration = {
export const ExternalIncidentServiceSecretConfiguration = {
apiKeyId: schema.string(),
apiKeySecret: schema.string(),
};
export const ResilientSecretConfigurationSchema = schema.object(ResilientSecretConfiguration);
export const ExternalIncidentServiceSecretConfigurationSchema = schema.object(
ExternalIncidentServiceSecretConfiguration
);
export const ExecutorSubActionSchema = schema.oneOf([
schema.literal('getIncident'),
schema.literal('pushToService'),
schema.literal('handshake'),
schema.literal('incidentTypes'),
schema.literal('severity'),
]);
export const ExecutorSubActionPushParamsSchema = schema.object({
savedObjectId: schema.string(),
title: schema.string(),
description: schema.nullable(schema.string()),
externalId: schema.nullable(schema.string()),
incidentTypes: schema.nullable(schema.arrayOf(schema.number())),
severityCode: schema.nullable(schema.number()),
// TODO: remove later - need for support Case push multiple comments
comments: schema.nullable(schema.arrayOf(CommentSchema)),
...EntityInformation,
});
export const ExecutorSubActionGetIncidentParamsSchema = schema.object({
externalId: schema.string(),
});
// Reserved for future implementation
export const ExecutorSubActionHandshakeParamsSchema = schema.object({});
export const ExecutorSubActionGetIncidentTypesParamsSchema = schema.object({});
export const ExecutorSubActionGetSeverityParamsSchema = 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,
}),
schema.object({
subAction: schema.literal('incidentTypes'),
subActionParams: ExecutorSubActionGetIncidentTypesParamsSchema,
}),
schema.object({
subAction: schema.literal('severity'),
subActionParams: ExecutorSubActionGetSeverityParamsSchema,
}),
]);

View file

@ -8,9 +8,11 @@ import axios from 'axios';
import { createExternalService, getValueTextContent, formatUpdateRequest } from './service';
import * as utils from '../lib/axios_utils';
import { ExternalService } from '../case/types';
import { ExternalService } from './types';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { incidentTypes, severity } from './mocks';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
@ -41,6 +43,8 @@ const mockIncidentUpdate = (withUpdateError = false) => {
format: 'html',
content: 'description',
},
incident_type_ids: [1001, 16, 12],
severity_code: 6,
},
}));
@ -246,7 +250,12 @@ describe('IBM Resilient service', () => {
}));
const res = await service.createIncident({
incident: { name: 'title', description: 'desc' },
incident: {
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 6,
},
});
expect(res).toEqual({
@ -269,12 +278,18 @@ describe('IBM Resilient service', () => {
}));
await service.createIncident({
incident: { name: 'title', description: 'desc' },
incident: {
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 6,
},
});
expect(requestMock).toHaveBeenCalledWith({
axios,
url: 'https://resilient.elastic.co/rest/orgs/201/incidents',
url:
'https://resilient.elastic.co/rest/orgs/201/incidents?text_content_output_format=objects_convert',
logger,
method: 'post',
data: {
@ -284,6 +299,8 @@ describe('IBM Resilient service', () => {
content: 'desc',
},
discovered_date: TIMESTAMP,
incident_type_ids: [{ id: 1001 }],
severity_code: { id: 6 },
},
});
});
@ -295,7 +312,12 @@ describe('IBM Resilient service', () => {
expect(
service.createIncident({
incident: { name: 'title', description: 'desc' },
incident: {
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 6,
},
})
).rejects.toThrow(
'[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred'
@ -308,7 +330,12 @@ describe('IBM Resilient service', () => {
mockIncidentUpdate();
const res = await service.updateIncident({
incidentId: '1',
incident: { name: 'title_updated', description: 'desc_updated' },
incident: {
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 6,
},
});
expect(res).toEqual({
@ -324,7 +351,12 @@ describe('IBM Resilient service', () => {
await service.updateIncident({
incidentId: '1',
incident: { name: 'title_updated', description: 'desc_updated' },
incident: {
name: 'title_updated',
description: 'desc_updated',
incidentTypes: [1001],
severityCode: 5,
},
});
// Incident update makes three calls to the API.
@ -356,6 +388,28 @@ describe('IBM Resilient service', () => {
},
},
},
{
field: {
name: 'incident_type_ids',
},
old_value: {
ids: [1001, 16, 12],
},
new_value: {
ids: [1001],
},
},
{
field: {
name: 'severity_code',
},
old_value: {
id: 6,
},
new_value: {
id: 5,
},
},
],
},
});
@ -367,7 +421,12 @@ describe('IBM Resilient service', () => {
expect(
service.updateIncident({
incidentId: '1',
incident: { name: 'title', description: 'desc' },
incident: {
name: 'title',
description: 'desc',
incidentTypes: [1001],
severityCode: 5,
},
})
).rejects.toThrow(
'[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred'
@ -386,8 +445,14 @@ describe('IBM Resilient service', () => {
const res = await service.createComment({
incidentId: '1',
comment: { comment: 'comment', commentId: 'comment-1' },
field: 'comments',
comment: {
comment: 'comment',
commentId: 'comment-1',
createdBy: null,
createdAt: null,
updatedAt: null,
updatedBy: null,
},
});
expect(res).toEqual({
@ -407,8 +472,14 @@ describe('IBM Resilient service', () => {
await service.createComment({
incidentId: '1',
comment: { comment: 'comment', commentId: 'comment-1' },
field: 'my_field',
comment: {
comment: 'comment',
commentId: 'comment-1',
createdBy: null,
createdAt: null,
updatedAt: null,
updatedBy: null,
},
});
expect(requestMock).toHaveBeenCalledWith({
@ -434,12 +505,82 @@ describe('IBM Resilient service', () => {
expect(
service.createComment({
incidentId: '1',
comment: { comment: 'comment', commentId: 'comment-1' },
field: 'comments',
comment: {
comment: 'comment',
commentId: 'comment-1',
createdBy: null,
createdAt: null,
updatedAt: null,
updatedBy: null,
},
})
).rejects.toThrow(
'[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred'
);
});
});
describe('getIncidentTypes', () => {
test('it creates the incident correctly', async () => {
requestMock.mockImplementation(() => ({
data: {
values: incidentTypes,
},
}));
const res = await service.getIncidentTypes();
expect(res).toEqual([
{ id: 17, name: 'Communication error (fax; email)' },
{ id: 1001, name: 'Custom type' },
]);
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
expect(service.getIncidentTypes()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.'
);
});
});
describe('getSeverity', () => {
test('it creates the incident correctly', async () => {
requestMock.mockImplementation(() => ({
data: {
values: severity,
},
}));
const res = await service.getSeverity();
expect(res).toEqual([
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
]);
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
expect(service.getIncidentTypes()).rejects.toThrow(
'[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.'
);
});
});
});

View file

@ -5,44 +5,56 @@
*/
import axios from 'axios';
import { omitBy, isNil } from 'lodash/fp';
import { Logger } from '../../../../../../src/core/server';
import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types';
import {
ExternalServiceCredentials,
ExternalService,
ExternalServiceParams,
CreateCommentParams,
UpdateIncidentParams,
CreateIncidentParams,
CreateIncidentData,
ResilientPublicConfigurationType,
ResilientSecretConfigurationType,
CreateIncidentRequest,
UpdateIncidentRequest,
CreateCommentRequest,
UpdateFieldText,
UpdateFieldTextArea,
GetValueTextContentResponse,
} from './types';
import * as i18n from './translations';
import { getErrorMessage, request } from '../lib/axios_utils';
import { ProxySettings } from '../../types';
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 => {
value: string | number | number[]
): GetValueTextContentResponse => {
if (field === 'description') {
return {
textarea: {
format: 'html',
content: value,
content: value as string,
},
};
}
if (field === 'incidentTypes') {
return {
ids: value as number[],
};
}
if (field === 'severityCode') {
return {
id: value as number,
};
}
return {
text: value,
text: value as string,
};
};
@ -51,11 +63,30 @@ export const formatUpdateRequest = ({
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]),
})),
changes: Object.keys(newIncident as Record<string, unknown>).map((key) => {
let name = key;
if (key === 'incidentTypes') {
name = 'incident_type_ids';
}
if (key === 'severityCode') {
name = 'severity_code';
}
return {
field: { name },
// TODO: Fix ugly casting
old_value: getValueTextContent(
key,
(oldIncident as Record<string, unknown>)[name] as string
),
new_value: getValueTextContent(
key,
(newIncident as Record<string, unknown>)[key] as string
),
};
}),
};
};
@ -72,8 +103,12 @@ export const createExternalService = (
}
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
const incidentUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/orgs/${orgId}/${INCIDENT_URL}`;
const commentUrl = `${incidentUrl}/{inc_id}/${COMMENT_URL}`;
const orgUrl = `${urlWithoutTrailingSlash}/rest/orgs/${orgId}`;
const incidentUrl = `${orgUrl}/incidents`;
const commentUrl = `${incidentUrl}/{inc_id}/comments`;
const incidentFieldsUrl = `${orgUrl}/types/incident/fields`;
const incidentTypesUrl = `${incidentFieldsUrl}/incident_type_ids`;
const severityUrl = `${incidentFieldsUrl}/severity_code`;
const axiosInstance = axios.create({
auth: { username: apiKeyId, password: apiKeySecret },
});
@ -101,26 +136,48 @@ export const createExternalService = (
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}`)
getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}.`)
);
}
};
const createIncident = async ({ incident }: ExternalServiceParams) => {
const createIncident = async ({ incident }: CreateIncidentParams) => {
let data: CreateIncidentData = {
name: incident.name,
discovered_date: Date.now(),
};
if (incident.description) {
data = {
...data,
description: {
format: 'html',
content: incident.description ?? '',
},
};
}
if (incident.incidentTypes) {
data = {
...data,
incident_type_ids: incident.incidentTypes.map((id) => ({ id })),
};
}
if (incident.severityCode) {
data = {
...data,
severity_code: { id: incident.severityCode },
};
}
try {
const res = await request<CreateIncidentRequest>({
const res = await request({
axios: axiosInstance,
url: `${incidentUrl}`,
url: `${incidentUrl}?text_content_output_format=objects_convert`,
method: 'post',
logger,
data: {
...incident,
description: {
format: 'html',
content: incident.description ?? '',
},
discovered_date: Date.now(),
},
data,
proxySettings,
});
@ -132,17 +189,20 @@ export const createExternalService = (
};
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`)
getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}.`)
);
}
};
const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => {
const updateIncident = async ({ incidentId, incident }: UpdateIncidentParams) => {
try {
const latestIncident = await getIncident(incidentId);
const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident: incident });
const res = await request<UpdateIncidentRequest>({
// Remove null or undefined values. Allowing null values sets the field in IBM Resilient to empty.
const newIncident = omitBy(isNil, incident);
const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident });
const res = await request({
axios: axiosInstance,
method: 'patch',
url: `${incidentUrl}/${incidentId}`,
@ -173,9 +233,9 @@ export const createExternalService = (
}
};
const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => {
const createComment = async ({ incidentId, comment }: CreateCommentParams) => {
try {
const res = await request<CreateCommentRequest>({
const res = await request({
axios: axiosInstance,
method: 'post',
url: getCommentsURL(incidentId),
@ -193,16 +253,62 @@ export const createExternalService = (
throw new Error(
getErrorMessage(
i18n.NAME,
`Unable to create comment at incident with id ${incidentId}. Error: ${error.message}`
`Unable to create comment at incident with id ${incidentId}. Error: ${error.message}.`
)
);
}
};
const getIncidentTypes = async () => {
try {
const res = await request({
axios: axiosInstance,
method: 'get',
url: incidentTypesUrl,
logger,
proxySettings,
});
const incidentTypes = res.data?.values ?? [];
return incidentTypes.map((type: { value: string; label: string }) => ({
id: type.value,
name: type.label,
}));
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to get incident types. Error: ${error.message}.`)
);
}
};
const getSeverity = async () => {
try {
const res = await request({
axios: axiosInstance,
method: 'get',
url: severityUrl,
logger,
proxySettings,
});
const incidentTypes = res.data?.values ?? [];
return incidentTypes.map((type: { value: string; label: string }) => ({
id: type.value,
name: type.label,
}));
} catch (error) {
throw new Error(
getErrorMessage(i18n.NAME, `Unable to get severity. Error: ${error.message}.`)
);
}
};
return {
getIncident,
createIncident,
updateIncident,
createComment,
getIncidentTypes,
getSeverity,
};
};

View file

@ -9,3 +9,19 @@ import { i18n } from '@kbn/i18n';
export const NAME = i18n.translate('xpack.actions.builtin.case.resilientTitle', {
defaultMessage: 'IBM Resilient',
});
export const ALLOWED_HOSTS_ERROR = (message: string) =>
i18n.translate('xpack.actions.builtin.configuration.apiAllowedHostsError', {
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.servicenow.configuration.emptyMapping',
{
defaultMessage: '[incidentConfiguration.mapping]: expected non-empty but got empty',
}
);

View file

@ -4,29 +4,175 @@
* 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 { ResilientPublicConfigurationSchema, ResilientSecretConfigurationSchema } from './schema';
import {
ExternalIncidentServiceConfigurationSchema,
ExternalIncidentServiceSecretConfigurationSchema,
ExecutorParamsSchema,
ExecutorSubActionPushParamsSchema,
ExecutorSubActionGetIncidentParamsSchema,
ExecutorSubActionHandshakeParamsSchema,
ExecutorSubActionGetIncidentTypesParamsSchema,
ExecutorSubActionGetSeverityParamsSchema,
} from './schema';
export type ResilientPublicConfigurationType = TypeOf<typeof ResilientPublicConfigurationSchema>;
export type ResilientSecretConfigurationType = TypeOf<typeof ResilientSecretConfigurationSchema>;
import { ActionsConfigurationUtilities } from '../../actions_config';
import { Logger } from '../../../../../../src/core/server';
interface CreateIncidentBasicRequestArgs {
import { IncidentConfigurationSchema } from '../case/schema';
import { Comment } from '../case/types';
export type ResilientPublicConfigurationType = TypeOf<
typeof ExternalIncidentServiceConfigurationSchema
>;
export type ResilientSecretConfigurationType = TypeOf<
typeof ExternalIncidentServiceSecretConfigurationSchema
>;
export type ExecutorParams = TypeOf<typeof ExecutorParamsSchema>;
export type ExecutorSubActionPushParams = TypeOf<typeof ExecutorSubActionPushParamsSchema>;
export type IncidentConfiguration = TypeOf<typeof IncidentConfigurationSchema>;
export interface ExternalServiceCredentials {
config: Record<string, unknown>;
secrets: Record<string, unknown>;
}
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 ExternalServiceCommentResponse {
commentId: string;
pushedDate: string;
externalCommentId?: string;
}
export type ExternalServiceParams = Record<string, unknown>;
export type Incident = Pick<
ExecutorSubActionPushParams,
'description' | 'incidentTypes' | 'severityCode'
> & {
name: string;
description: string;
discovered_date: number;
};
export interface CreateIncidentParams {
incident: Incident;
}
interface Comment {
text: { format: string; content: string };
export interface UpdateIncidentParams {
incidentId: string;
incident: Incident;
}
interface CreateIncidentRequestArgs extends CreateIncidentBasicRequestArgs {
comments?: Comment[];
export interface CreateCommentParams {
incidentId: string;
comment: Comment;
}
export type GetIncidentTypesResponse = Array<{ id: string; name: string }>;
export type GetSeverityResponse = Array<{ id: string; name: string }>;
export interface ExternalService {
getIncident: (id: string) => Promise<ExternalServiceParams | undefined>;
createIncident: (params: CreateIncidentParams) => Promise<ExternalServiceIncidentResponse>;
updateIncident: (params: UpdateIncidentParams) => Promise<ExternalServiceIncidentResponse>;
createComment: (params: CreateCommentParams) => Promise<ExternalServiceCommentResponse>;
getIncidentTypes: () => Promise<GetIncidentTypesResponse>;
getSeverity: () => Promise<GetSeverityResponse>;
}
export interface PushToServiceApiParams extends ExecutorSubActionPushParams {
externalObject: Record<string, any>;
}
export type ExecutorSubActionGetIncidentTypesParams = TypeOf<
typeof ExecutorSubActionGetIncidentTypesParamsSchema
>;
export type ExecutorSubActionGetSeverityParams = TypeOf<
typeof ExecutorSubActionGetSeverityParamsSchema
>;
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
mapping: Map<string, any> | null;
}
export type ExecutorSubActionGetIncidentParams = TypeOf<
typeof ExecutorSubActionGetIncidentParamsSchema
>;
export type ExecutorSubActionHandshakeParams = TypeOf<
typeof ExecutorSubActionHandshakeParamsSchema
>;
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: PushToServiceApiParams;
logger: Logger;
}
export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: ExecutorSubActionGetIncidentParams;
}
export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: ExecutorSubActionHandshakeParams;
}
export interface GetIncidentTypesHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionGetIncidentTypesParams;
}
export interface GetSeverityHandlerArgs {
externalService: ExternalService;
params: ExecutorSubActionGetSeverityParams;
}
export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
comments?: ExternalServiceCommentResponse[];
}
export interface ExternalServiceApi {
handshake: (args: HandshakeApiHandlerArgs) => Promise<void>;
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<void>;
incidentTypes: (args: GetIncidentTypesHandlerArgs) => Promise<GetIncidentTypesResponse>;
severity: (args: GetSeverityHandlerArgs) => Promise<GetSeverityResponse>;
}
export type ResilientExecutorResultData =
| PushToServiceResponse
| GetIncidentTypesResponse
| GetSeverityResponse;
export interface UpdateFieldText {
text: string;
}
export interface UpdateFieldText {
text: string;
}
export interface UpdateIdsField {
ids: number[];
}
export interface UpdateIdField {
id: number;
}
export interface UpdateFieldTextArea {
textarea: { format: 'html' | 'text'; content: string };
@ -34,13 +180,24 @@ export interface UpdateFieldTextArea {
interface UpdateField {
field: { name: string };
old_value: UpdateFieldText | UpdateFieldTextArea;
new_value: UpdateFieldText | UpdateFieldTextArea;
old_value: UpdateFieldText | UpdateFieldTextArea | UpdateIdsField | UpdateIdField;
new_value: UpdateFieldText | UpdateFieldTextArea | UpdateIdsField | UpdateIdField;
}
export type CreateIncidentRequest = CreateIncidentRequestArgs;
export type CreateCommentRequest = Comment;
export interface UpdateIncidentRequest {
changes: UpdateField[];
}
export type GetValueTextContentResponse =
| UpdateFieldText
| UpdateFieldTextArea
| UpdateIdsField
| UpdateIdField;
export interface CreateIncidentData {
name: string;
discovered_date: number;
description?: { format: string; content: string };
incident_type_ids?: Array<{ id: number }>;
severity_code?: { id: number };
}

View file

@ -4,8 +4,38 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { validateCommonConfig, validateCommonSecrets } from '../case/validators';
import { ExternalServiceValidation } from '../case/types';
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.ensureUriAllowed(configObject.apiUrl);
} catch (allowedListError) {
return i18n.ALLOWED_HOSTS_ERROR(allowedListError.message);
}
};
export const validateCommonSecrets = (
configurationUtilities: ActionsConfigurationUtilities,
secrets: ResilientSecretConfigurationType
) => {};
export const validate: ExternalServiceValidation = {
config: validateCommonConfig,

View file

@ -3,7 +3,6 @@
* 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,
@ -12,12 +11,11 @@ import {
ExternalServiceApi,
PushToServiceApiParams,
PushToServiceResponse,
Incident,
} from './types';
// TODO: to remove, need to support Case
import { transformers } from '../case/transformers';
import { TransformFieldsArgs, Comment, EntityInformation } from '../case/common_types';
import { prepareFieldsForTransformation } from '../case/utils';
import { transformFields, transformComments, prepareFieldsForTransformation } from '../case/utils';
const handshakeHandler = async ({
externalService,
@ -62,7 +60,7 @@ const pushToServiceHandler = async ({
defaultPipes,
});
incident = transformFields({
incident = transformFields<PushToServiceApiParams, ExternalServiceParams, Incident>({
params,
fields,
currentIncident,
@ -117,47 +115,6 @@ const pushToServiceHandler = async ({
return res;
};
export const transformFields = ({
params,
fields,
currentIncident,
}: TransformFieldsArgs<PushToServiceApiParams, ExternalServiceParams>): Record<string, string> => {
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: getEntity(params),
previousValue: currentIncident ? currentIncident[cur.key] : '',
}).value,
};
}, {});
};
export const transformComments = (comments: Comment[], pipes: string[]): Comment[] => {
return comments.map((c) => ({
...c,
comment: flow(...pipes.map((p) => transformers[p]))({
value: c.comment,
date: c.updatedAt ?? c.createdAt,
user: getEntity(c),
}).value,
}));
};
export const getEntity = (entity: EntityInformation): string =>
(entity.updatedBy != null
? entity.updatedBy.fullName
? entity.updatedBy.fullName
: entity.updatedBy.username
: entity.createdBy != null
? entity.createdBy.fullName
? entity.createdBy.fullName
: entity.createdBy.username
: '') ?? '';
export const api: ExternalServiceApi = {
handshake: handshakeHandler,
pushToService: pushToServiceHandler,

View file

@ -5,7 +5,7 @@
*/
import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types';
import { MapRecord } from '../case/common_types';
import { MapRecord } from '../case/types';
const createMock = (): jest.Mocked<ExternalService> => {
const service = {

View file

@ -5,11 +5,7 @@
*/
import { schema } from '@kbn/config-schema';
import {
CommentSchema,
EntityInformation,
IncidentConfigurationSchema,
} from '../case/common_schema';
import { CommentSchema, EntityInformation, IncidentConfigurationSchema } from '../case/schema';
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),

View file

@ -16,8 +16,8 @@ import {
ExecutorSubActionHandshakeParamsSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { ExternalServiceCommentResponse } from '../case/common_types';
import { IncidentConfigurationSchema } from '../case/common_schema';
import { ExternalServiceCommentResponse } from '../case/types';
import { IncidentConfigurationSchema } from '../case/schema';
import { Logger } from '../../../../../../src/core/server';
export type ServiceNowPublicConfigurationType = TypeOf<
@ -82,6 +82,13 @@ export type ExecutorSubActionHandshakeParams = TypeOf<
typeof ExecutorSubActionHandshakeParamsSchema
>;
export type Incident = Pick<
ExecutorSubActionPushParams,
'description' | 'severity' | 'urgency' | 'impact'
> & {
short_description: string;
};
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: PushToServiceApiParams;
secrets: Record<string, unknown>;

View file

@ -13,6 +13,7 @@ import {
SUPPORTED_CONNECTORS,
SERVICENOW_ACTION_TYPE_ID,
JIRA_ACTION_TYPE_ID,
RESILIENT_ACTION_TYPE_ID,
} from '../../../../../common/constants';
/*
@ -37,8 +38,12 @@ export function initCaseConfigureGetActionConnector({ caseService, router }: Rou
(action) =>
SUPPORTED_CONNECTORS.includes(action.actionTypeId) &&
// Need this filtering temporary to display only Case owned ServiceNow connectors
(![SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID].includes(action.actionTypeId) ||
([SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID].includes(action.actionTypeId) &&
(![SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes(
action.actionTypeId
) ||
([SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes(
action.actionTypeId
) &&
action.config?.isCaseOwned === true))
);
return response.ok({ body: results });

View file

@ -77,7 +77,7 @@ export const connectorsMock: Connector[] = [
name: 'Jira',
config: {
apiUrl: 'https://instance.atlassian.ne',
casesConfiguration: {
incidentConfiguration: {
mapping: [
{
source: 'title',

View file

@ -1,157 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useEffect } from 'react';
import { EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer } from '@elastic/eui';
import { isEmpty, get } from 'lodash/fp';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ActionConnectorFieldsProps } from '../../../../../../../triggers_actions_ui/public/types';
import { FieldMapping } from '../../../../../cases/components/configure_cases/field_mapping';
import { CasesConfigurationMapping } from '../../../../../cases/containers/configure/types';
import * as i18n from '../../translations';
import { ActionConnector, ConnectorFlyoutHOCProps } from '../../types';
import { createDefaultMapping } from '../../utils';
import { connectorsConfiguration } from '../../config';
export const withConnectorFlyout = <T extends ActionConnector>({
ConnectorFormComponent,
connectorActionTypeId,
secretKeys = [],
configKeys = [],
}: ConnectorFlyoutHOCProps<T>) => {
const ConnectorFlyout: React.FC<ActionConnectorFieldsProps<T>> = ({
action,
editActionConfig,
editActionSecrets,
errors,
}) => {
/* We do not provide defaults values to the fields (like empty string for apiUrl) intentionally.
* If we do, errors will be shown the first time the flyout is open even though the user did not
* interact with the form. Also, we would like to show errors for empty fields provided by the user.
/*/
const { apiUrl, casesConfiguration: { mapping = [] } = {} } = action.config;
const configKeysWithDefault = [...configKeys, 'apiUrl'];
const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl != null;
/**
* We need to distinguish between the add flyout and the edit flyout.
* useEffect will run only once on component mount.
* This guarantees that the function below will run only once.
* On the first render of the component the apiUrl can be either undefined or filled.
* If it is filled then we are on the edit flyout. Otherwise we are on the add flyout.
*/
useEffect(() => {
if (!isEmpty(apiUrl)) {
secretKeys.forEach((key: string) => editActionSecrets(key, ''));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isEmpty(mapping)) {
editActionConfig('casesConfiguration', {
...action.config.casesConfiguration,
mapping: createDefaultMapping(connectorsConfiguration[connectorActionTypeId].fields),
});
}
const handleOnChangeActionConfig = useCallback(
(key: string, value: string) => editActionConfig(key, value),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const handleOnBlurActionConfig = useCallback(
(key: string) => {
if (configKeysWithDefault.includes(key) && get(key, action.config) == null) {
editActionConfig(key, '');
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[action.config]
);
const handleOnChangeSecretConfig = useCallback(
(key: string, value: string) => editActionSecrets(key, value),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const handleOnBlurSecretConfig = useCallback(
(key: string) => {
if (secretKeys.includes(key) && get(key, action.secrets) == null) {
editActionSecrets(key, '');
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[action.secrets]
);
const handleOnChangeMappingConfig = useCallback(
(newMapping: CasesConfigurationMapping[]) =>
editActionConfig('casesConfiguration', {
...action.config.casesConfiguration,
mapping: newMapping,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[action.config]
);
return (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="apiUrl"
fullWidth
error={errors.apiUrl}
isInvalid={isApiUrlInvalid}
label={i18n.API_URL_LABEL}
>
<EuiFieldText
fullWidth
isInvalid={isApiUrlInvalid}
name="apiUrl"
value={apiUrl || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="apiUrlFromInput"
placeholder="https://<site-url>"
onChange={(evt) => handleOnChangeActionConfig('apiUrl', evt.target.value)}
onBlur={handleOnBlurActionConfig.bind(null, 'apiUrl')}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<ConnectorFormComponent
errors={errors}
action={action}
onChangeSecret={handleOnChangeSecretConfig}
onBlurSecret={handleOnBlurSecretConfig}
onChangeConfig={handleOnChangeActionConfig}
onBlurConfig={handleOnBlurActionConfig}
/>
<EuiSpacer size="l" />
<EuiFlexGroup>
<EuiFlexItem>
<FieldMapping
disabled={true}
connectorActionTypeId={connectorActionTypeId}
mapping={mapping as CasesConfigurationMapping[]}
onChangeMapping={handleOnChangeMappingConfig}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
return ConnectorFlyout;
};

View file

@ -9,12 +9,12 @@
import {
ServiceNowConnectorConfiguration,
JiraConnectorConfiguration,
ResilientConnectorConfiguration,
} from '../../../../../triggers_actions_ui/public/common';
import { connector as resilientConnectorConfig } from './resilient/config';
import { ConnectorConfiguration } from './types';
export const connectorsConfiguration: Record<string, ConnectorConfiguration> = {
'.servicenow': ServiceNowConnectorConfiguration as ConnectorConfiguration,
'.jira': JiraConnectorConfiguration as ConnectorConfiguration,
'.resilient': resilientConnectorConfig,
'.resilient': ResilientConnectorConfiguration as ConnectorConfiguration,
};

View file

@ -1,114 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiFieldPassword,
EuiSpacer,
} from '@elastic/eui';
import * as i18n from './translations';
import { ConnectorFlyoutFormProps } from '../types';
import { ResilientActionConnector } from './types';
import { withConnectorFlyout } from '../components/connector_flyout';
const resilientConnectorForm: React.FC<ConnectorFlyoutFormProps<ResilientActionConnector>> = ({
errors,
action,
onChangeSecret,
onBlurSecret,
onChangeConfig,
onBlurConfig,
}) => {
const { orgId } = action.config;
const { apiKeyId, apiKeySecret } = action.secrets;
const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId != null;
const isApiKeyIdInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId != null;
const isApiKeySecretInvalid: boolean = errors.apiKeySecret.length > 0 && apiKeySecret != null;
return (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="connector-resilient-org-id"
fullWidth
error={errors.orgId}
isInvalid={isOrgIdInvalid}
label={i18n.RESILIENT_PROJECT_KEY_LABEL}
>
<EuiFieldText
fullWidth
isInvalid={isOrgIdInvalid}
name="connector-resilient-project-key"
value={orgId || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="connector-resilient-project-key-form-input"
onChange={(evt) => onChangeConfig('orgId', evt.target.value)}
onBlur={() => onBlurConfig('orgId')}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="connector-resilient-apiKeyId"
fullWidth
error={errors.apiKeyId}
isInvalid={isApiKeyIdInvalid}
label={i18n.RESILIENT_API_KEY_ID_LABEL}
>
<EuiFieldText
fullWidth
isInvalid={isApiKeyIdInvalid}
name="connector-resilient-apiKeyId"
value={apiKeyId || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="connector-resilient-apiKeyId-form-input"
onChange={(evt) => onChangeSecret('apiKeyId', evt.target.value)}
onBlur={() => onBlurSecret('apiKeyId')}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="connector-resilient-apiKeySecret"
fullWidth
error={errors.apiKeySecret}
isInvalid={isApiKeySecretInvalid}
label={i18n.RESILIENT_API_KEY_SECRET_LABEL}
>
<EuiFieldPassword
fullWidth
isInvalid={isApiKeySecretInvalid}
name="connector-resilient-apiKeySecret"
value={apiKeySecret || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="connector-resilient-apiKeySecret-form-input"
onChange={(evt) => onChangeSecret('apiKeySecret', evt.target.value)}
onBlur={() => onBlurSecret('apiKeySecret')}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
export const resilientConnectorFlyout = withConnectorFlyout<ResilientActionConnector>({
ConnectorFormComponent: resilientConnectorForm,
secretKeys: ['apiKeyId', 'apiKeySecret'],
configKeys: ['orgId'],
connectorActionTypeId: '.resilient',
});
// eslint-disable-next-line import/no-default-export
export { resilientConnectorFlyout as default };

View file

@ -1,54 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { lazy } from 'react';
import {
ValidationResult,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../../../triggers_actions_ui/public/types';
import { connector } from './config';
import { createActionType } from '../utils';
import logo from './logo.svg';
import { ResilientActionConnector } from './types';
import * as i18n from './translations';
interface Errors {
orgId: string[];
apiKeyId: string[];
apiKeySecret: string[];
}
const validateConnector = (action: ResilientActionConnector): ValidationResult => {
const errors: Errors = {
orgId: [],
apiKeyId: [],
apiKeySecret: [],
};
if (!action.config.orgId) {
errors.orgId = [...errors.orgId, i18n.RESILIENT_PROJECT_KEY_REQUIRED];
}
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 { errors };
};
export const getActionType = createActionType({
id: connector.id,
iconClass: logo,
selectMessage: i18n.RESILIENT_DESC,
actionTypeTitle: connector.name,
validateConnector,
actionConnectorFields: lazy(() => import('./flyout')),
});

View file

@ -1,72 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export * from '../translations';
export const RESILIENT_DESC = i18n.translate(
'xpack.securitySolution.case.connectors.resilient.selectMessageText',
{
defaultMessage: 'Push or update Security case data to a new issue in Resilient',
}
);
export const RESILIENT_TITLE = i18n.translate(
'xpack.securitySolution.case.connectors.resilient.actionTypeTitle',
{
defaultMessage: 'IBM Resilient',
}
);
export const RESILIENT_PROJECT_KEY_LABEL = i18n.translate(
'xpack.securitySolution.case.connectors.resilient.orgId',
{
defaultMessage: 'Organization ID',
}
);
export const RESILIENT_PROJECT_KEY_REQUIRED = i18n.translate(
'xpack.securitySolution.case.connectors.resilient.requiredOrgIdTextField',
{
defaultMessage: 'Organization ID is required',
}
);
export const RESILIENT_API_KEY_ID_LABEL = i18n.translate(
'xpack.securitySolution.case.connectors.resilient.apiKeyId',
{
defaultMessage: 'API key ID',
}
);
export const RESILIENT_API_KEY_ID_REQUIRED = i18n.translate(
'xpack.securitySolution.case.connectors.resilient.requiredApiKeyIdTextField',
{
defaultMessage: 'API key ID is required',
}
);
export const RESILIENT_API_KEY_SECRET_LABEL = i18n.translate(
'xpack.securitySolution.case.connectors.resilient.apiKeySecret',
{
defaultMessage: 'API key secret',
}
);
export const RESILIENT_API_KEY_SECRET_REQUIRED = i18n.translate(
'xpack.securitySolution.case.connectors.resilient.requiredApiKeySecretTextField',
{
defaultMessage: 'API key secret is required',
}
);
export const MAPPING_FIELD_NAME = i18n.translate(
'xpack.securitySolution.case.configureCases.mappingFieldName',
{
defaultMessage: 'Name',
}
);

View file

@ -1,22 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable no-restricted-imports */
/* eslint-disable @kbn/eslint/no-restricted-paths */
import {
ResilientPublicConfigurationType,
ResilientSecretConfigurationType,
} from '../../../../../../actions/server/builtin_action_types/resilient/types';
export { ResilientFieldsType } from '../../../../../../case/common/api/connectors';
export * from '../types';
export interface ResilientActionConnector {
config: ResilientPublicConfigurationType;
secrets: ResilientSecretConfigurationType;
}

View file

@ -1,98 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const API_URL_LABEL = i18n.translate(
'xpack.securitySolution.case.connectors.common.apiUrlTextFieldLabel',
{
defaultMessage: 'URL',
}
);
export const API_URL_REQUIRED = i18n.translate(
'xpack.securitySolution.case.connectors.common.requiredApiUrlTextField',
{
defaultMessage: 'URL is required',
}
);
export const API_URL_INVALID = i18n.translate(
'xpack.securitySolution.case.connectors.common.invalidApiUrlTextField',
{
defaultMessage: 'URL is invalid',
}
);
export const USERNAME_LABEL = i18n.translate(
'xpack.securitySolution.case.connectors.common.usernameTextFieldLabel',
{
defaultMessage: 'Username',
}
);
export const USERNAME_REQUIRED = i18n.translate(
'xpack.securitySolution.case.connectors.common.requiredUsernameTextField',
{
defaultMessage: 'Username is required',
}
);
export const PASSWORD_LABEL = i18n.translate(
'xpack.securitySolution.case.connectors.common.passwordTextFieldLabel',
{
defaultMessage: 'Password',
}
);
export const PASSWORD_REQUIRED = i18n.translate(
'xpack.securitySolution.case.connectors.common.requiredPasswordTextField',
{
defaultMessage: 'Password is required',
}
);
export const API_TOKEN_LABEL = i18n.translate(
'xpack.securitySolution.case.connectors.common.apiTokenTextFieldLabel',
{
defaultMessage: 'API token',
}
);
export const API_TOKEN_REQUIRED = i18n.translate(
'xpack.securitySolution.case.connectors.common.requiredApiTokenTextField',
{
defaultMessage: 'API token is required',
}
);
export const EMAIL_LABEL = i18n.translate(
'xpack.securitySolution.case.connectors.common.emailTextFieldLabel',
{
defaultMessage: 'Email',
}
);
export const EMAIL_REQUIRED = i18n.translate(
'xpack.securitySolution.case.connectors.common.requiredEmailTextField',
{
defaultMessage: 'Email is required',
}
);
export const MAPPING_FIELD_DESC = i18n.translate(
'xpack.securitySolution.case.configureCases.mappingFieldDescription',
{
defaultMessage: 'Description',
}
);
export const MAPPING_FIELD_COMMENTS = i18n.translate(
'xpack.securitySolution.case.configureCases.mappingFieldComments',
{
defaultMessage: 'Comments',
}
);

View file

@ -4,12 +4,7 @@
* 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 { ActionType } from '../../../../../triggers_actions_ui/public';
import { IErrorObject } from '../../../../../triggers_actions_ui/public/types';
import { ExternalIncidentServiceConfiguration } from '../../../../../actions/server/builtin_action_types/case/types';
import {
ActionType as ThirdPartySupportedActions,
@ -29,34 +24,3 @@ export interface ConnectorConfiguration extends ActionType {
logo: string;
fields: Record<string, ThirdPartyField>;
}
export interface ActionConnector {
config: ExternalIncidentServiceConfiguration;
secrets: {};
}
export interface ActionConnectorParams {
message: string;
}
export interface ActionConnectorValidationErrors {
apiUrl: string[];
}
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<T>;
export interface ConnectorFlyoutFormProps<T> {
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<T> {
ConnectorFormComponent: React.FC<ConnectorFlyoutFormProps<T>>;
connectorActionTypeId: string;
configKeys?: string[];
secretKeys?: string[];
}

View file

@ -4,63 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
ActionTypeModel,
ValidationResult,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../../triggers_actions_ui/public/types';
import {
ActionConnector,
ActionConnectorParams,
ActionConnectorValidationErrors,
Optional,
ThirdPartyField,
} from './types';
import { isUrlInvalid } from './validators';
import * as i18n from './translations';
import { CasesConfigurationMapping } from '../../../cases/containers/configure/types';
export const createActionType = ({
id,
actionTypeTitle,
selectMessage,
iconClass,
validateConnector,
validateParams = connectorParamsValidator,
actionConnectorFields,
actionParamsFields = null,
}: Optional<ActionTypeModel, 'validateParams' | 'actionParamsFields'>) => (): ActionTypeModel => {
return {
id,
iconClass,
selectMessage,
actionTypeTitle,
validateConnector: (action: ActionConnector): ValidationResult => {
const errors: ActionConnectorValidationErrors = {
apiUrl: [],
};
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];
}
return { errors: { ...errors, ...validateConnector(action).errors } };
},
validateParams,
actionConnectorFields,
actionParamsFields,
};
};
const connectorParamsValidator = (actionParams: ActionConnectorParams): ValidationResult => {
return { errors: {} };
};
import { ThirdPartyField } from './types';
export const createDefaultMapping = (
fields: Record<string, ThirdPartyField>

View file

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

View file

@ -21,7 +21,6 @@ import {
import { Storage } from '../../../../src/plugins/kibana_utils/public';
import { initTelemetry } from './common/lib/telemetry';
import { KibanaServices } from './common/lib/kibana/services';
import { resilientActionType } from './common/lib/connectors';
import {
PluginSetup,
PluginStart,
@ -96,8 +95,6 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
});
}
plugins.triggers_actions_ui.actionTypeRegistry.register(resilientActionType());
const mountSecurityFactory = async () => {
const storage = new Storage(localStorage);
const [coreStart, startPlugins] = await core.getStartServices();

View file

@ -15107,9 +15107,6 @@
"xpack.securitySolution.case.configureCases.incidentManagementSystemDesc": "オプションとして、セキュリティケースを選択した外部のインシデント管理システムに接続できます。そうすると、選択したサードパーティシステム内でケースデータをインシデントとしてプッシュできます。",
"xpack.securitySolution.case.configureCases.incidentManagementSystemLabel": "インシデント管理システム",
"xpack.securitySolution.case.configureCases.incidentManagementSystemTitle": "外部のインシデント管理システムに接続",
"xpack.securitySolution.case.configureCases.mappingFieldComments": "コメント",
"xpack.securitySolution.case.configureCases.mappingFieldDescription": "説明",
"xpack.securitySolution.case.configureCases.mappingFieldName": "名前",
"xpack.securitySolution.case.configureCases.mappingFieldNotMapped": "マップされません",
"xpack.securitySolution.case.configureCases.noConnector": "コネクターを選択していません",
"xpack.securitySolution.case.configureCases.updateConnector": "コネクターを更新",
@ -15123,25 +15120,6 @@
"xpack.securitySolution.case.confirmDeleteCase.deleteCases": "ケースを削除",
"xpack.securitySolution.case.confirmDeleteCase.deleteTitle": "「{caseTitle}」を削除",
"xpack.securitySolution.case.confirmDeleteCase.selectedCases": "選択したケースを削除",
"xpack.securitySolution.case.connectors.common.apiTokenTextFieldLabel": "APIトークン",
"xpack.securitySolution.case.connectors.common.apiUrlTextFieldLabel": "URL",
"xpack.securitySolution.case.connectors.common.emailTextFieldLabel": "メール",
"xpack.securitySolution.case.connectors.common.invalidApiUrlTextField": "URLが無効です",
"xpack.securitySolution.case.connectors.common.passwordTextFieldLabel": "パスワード",
"xpack.securitySolution.case.connectors.common.requiredApiTokenTextField": "APIトークンが必要です",
"xpack.securitySolution.case.connectors.common.requiredApiUrlTextField": "URLが必要です",
"xpack.securitySolution.case.connectors.common.requiredEmailTextField": "電子メールが必要です",
"xpack.securitySolution.case.connectors.common.requiredPasswordTextField": "パスワードが必要です",
"xpack.securitySolution.case.connectors.common.requiredUsernameTextField": "ユーザー名が必要です",
"xpack.securitySolution.case.connectors.common.usernameTextFieldLabel": "ユーザー名",
"xpack.securitySolution.case.connectors.resilient.actionTypeTitle": "IBM Resilient",
"xpack.securitySolution.case.connectors.resilient.apiKeyId": "APIキーID",
"xpack.securitySolution.case.connectors.resilient.apiKeySecret": "APIキーシークレット",
"xpack.securitySolution.case.connectors.resilient.orgId": "組織ID",
"xpack.securitySolution.case.connectors.resilient.requiredApiKeyIdTextField": "APIキーIDが必要です",
"xpack.securitySolution.case.connectors.resilient.requiredApiKeySecretTextField": "APIキーシークレットが必要です",
"xpack.securitySolution.case.connectors.resilient.requiredOrgIdTextField": "組織IDが必要です",
"xpack.securitySolution.case.connectors.resilient.selectMessageText": "Resilientでセキュリティケースデータを更新するか、新しいインシデントにプッシュ",
"xpack.securitySolution.case.createCase.descriptionFieldRequiredError": "説明が必要です。",
"xpack.securitySolution.case.createCase.fieldTagsHelpText": "このケースの1つ以上のカスタム識別タグを入力します。新しいタグを開始するには、各タグの後でEnterを押します。",
"xpack.securitySolution.case.createCase.titleFieldRequiredError": "タイトルが必要です。",

View file

@ -15116,9 +15116,6 @@
"xpack.securitySolution.case.configureCases.incidentManagementSystemDesc": "您可能会根据需要将 Security 案例连接到选择的外部事件管理系统。这将允许您将案例数据作为事件推送到所选第三方系统。",
"xpack.securitySolution.case.configureCases.incidentManagementSystemLabel": "事件管理系统",
"xpack.securitySolution.case.configureCases.incidentManagementSystemTitle": "连接到外部事件管理系统",
"xpack.securitySolution.case.configureCases.mappingFieldComments": "注释",
"xpack.securitySolution.case.configureCases.mappingFieldDescription": "描述",
"xpack.securitySolution.case.configureCases.mappingFieldName": "名称",
"xpack.securitySolution.case.configureCases.mappingFieldNotMapped": "未映射",
"xpack.securitySolution.case.configureCases.noConnector": "未选择连接器",
"xpack.securitySolution.case.configureCases.updateConnector": "更新连接器",
@ -15132,25 +15129,6 @@
"xpack.securitySolution.case.confirmDeleteCase.deleteCases": "删除案例",
"xpack.securitySolution.case.confirmDeleteCase.deleteTitle": "删除“{caseTitle}”",
"xpack.securitySolution.case.confirmDeleteCase.selectedCases": "删除选定案例",
"xpack.securitySolution.case.connectors.common.apiTokenTextFieldLabel": "API 令牌",
"xpack.securitySolution.case.connectors.common.apiUrlTextFieldLabel": "URL",
"xpack.securitySolution.case.connectors.common.emailTextFieldLabel": "电子邮件",
"xpack.securitySolution.case.connectors.common.invalidApiUrlTextField": "URL 无效",
"xpack.securitySolution.case.connectors.common.passwordTextFieldLabel": "密码",
"xpack.securitySolution.case.connectors.common.requiredApiTokenTextField": "“API 令牌”必填",
"xpack.securitySolution.case.connectors.common.requiredApiUrlTextField": "“URL”必填",
"xpack.securitySolution.case.connectors.common.requiredEmailTextField": "“电子邮件”必填",
"xpack.securitySolution.case.connectors.common.requiredPasswordTextField": "“密码”必填",
"xpack.securitySolution.case.connectors.common.requiredUsernameTextField": "“用户名”必填",
"xpack.securitySolution.case.connectors.common.usernameTextFieldLabel": "用户名",
"xpack.securitySolution.case.connectors.resilient.actionTypeTitle": "IBM Resilient",
"xpack.securitySolution.case.connectors.resilient.apiKeyId": "API 密钥 ID",
"xpack.securitySolution.case.connectors.resilient.apiKeySecret": "API 密钥密码",
"xpack.securitySolution.case.connectors.resilient.orgId": "组织 ID",
"xpack.securitySolution.case.connectors.resilient.requiredApiKeyIdTextField": "“API 密钥 ID”必填",
"xpack.securitySolution.case.connectors.resilient.requiredApiKeySecretTextField": "“API 密钥密码”必填",
"xpack.securitySolution.case.connectors.resilient.requiredOrgIdTextField": "“组织 ID”必填",
"xpack.securitySolution.case.connectors.resilient.selectMessageText": "将 Security 案例数据推送或更新到 Resilient 中的新问题",
"xpack.securitySolution.case.createCase.descriptionFieldRequiredError": "描述必填。",
"xpack.securitySolution.case.createCase.fieldTagsHelpText": "为此案例键入一个或多个定制识别标记。在每个标记后按 Enter 键可开始新的标记。",
"xpack.securitySolution.case.createCase.titleFieldRequiredError": "标题必填。",

View file

@ -27,6 +27,7 @@ describe('EmailParamsFields renders', () => {
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
/>
);
expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy();
expect(
wrapper.find('[data-test-subj="toEmailAddressInput"]').first().prop('selectedOptions')

View file

@ -13,6 +13,7 @@ describe('IndexParamsFields renders', () => {
const actionParams = {
documents: [{ test: 123 }],
};
const wrapper = mountWithIntl(
<ParamsFields
actionParams={actionParams}

View file

@ -14,6 +14,7 @@ import { TypeRegistry } from '../../type_registry';
import { ActionTypeModel } from '../../../types';
import { getServiceNowActionType } from './servicenow';
import { getJiraActionType } from './jira';
import { getResilientActionType } from './resilient';
export function registerBuiltInActionTypes({
actionTypeRegistry,
@ -28,4 +29,5 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getWebhookActionType());
actionTypeRegistry.register(getServiceNowActionType());
actionTypeRegistry.register(getJiraActionType());
actionTypeRegistry.register(getResilientActionType());
}

View file

@ -22,6 +22,7 @@ describe('PagerDutyParamsFields renders', () => {
group: 'group',
class: 'test class',
};
const wrapper = mountWithIntl(
<PagerDutyParamsFields
actionParams={actionParams}

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HttpSetup } from 'kibana/public';
import { BASE_ACTION_API_PATH } from '../../../constants';
export async function getIncidentTypes({
http,
signal,
connectorId,
}: {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
}): Promise<Record<string, any>> {
return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, {
body: JSON.stringify({
params: { subAction: 'incidentTypes', subActionParams: {} },
}),
signal,
});
}
export async function getSeverity({
http,
signal,
connectorId,
}: {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
}): Promise<Record<string, any>> {
return await http.post(`${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, {
body: JSON.stringify({
params: { subAction: 'severity', subActionParams: {} },
}),
signal,
});
}

View file

@ -4,14 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ConnectorConfiguration } from './types';
import * as i18n from './translations';
import logo from './logo.svg';
export const connector: ConnectorConfiguration = {
export const connectorConfiguration = {
id: '.resilient',
name: i18n.RESILIENT_TITLE,
name: i18n.TITLE,
logo,
enabled: true,
enabledInConfig: true,

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { getActionType as resilientActionType } from './resilient';
export { getActionType as getResilientActionType } from './resilient';

View file

@ -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 { TypeRegistry } from '../../../type_registry';
import { registerBuiltInActionTypes } from '.././index';
import { ActionTypeModel } from '../../../../types';
import { ResilientActionConnector } from './types';
const ACTION_TYPE_ID = '.resilient';
let actionTypeModel: ActionTypeModel;
beforeAll(() => {
const actionTypeRegistry = new TypeRegistry<ActionTypeModel>();
registerBuiltInActionTypes({ actionTypeRegistry });
const getResult = actionTypeRegistry.get(ACTION_TYPE_ID);
if (getResult !== null) {
actionTypeModel = getResult;
}
});
describe('actionTypeRegistry.get() works', () => {
test('action type static data is as expected', () => {
expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID);
});
});
describe('resilient connector validation', () => {
test('connector validation succeeds when connector config is valid', () => {
const actionConnector = {
secrets: {
apiKeyId: 'email',
apiKeySecret: 'token',
},
id: 'test',
actionTypeId: '.resilient',
isPreconfigured: false,
name: 'resilient',
config: {
apiUrl: 'https://test/',
orgId: '201',
},
} as ResilientActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
apiUrl: [],
apiKeyId: [],
apiKeySecret: [],
orgId: [],
},
});
});
test('connector validation fails when connector config is not valid', () => {
const actionConnector = ({
secrets: {
apiKeyId: 'user',
},
id: '.jira',
actionTypeId: '.jira',
name: 'jira',
config: {},
} as unknown) as ResilientActionConnector;
expect(actionTypeModel.validateConnector(actionConnector)).toEqual({
errors: {
apiUrl: ['URL is required.'],
apiKeyId: [],
apiKeySecret: ['API key secret is required'],
orgId: ['Organization ID is required'],
},
});
});
});
describe('resilient action params validation', () => {
test('action params validation succeeds when action params is valid', () => {
const actionParams = {
subActionParams: { title: 'some title {{test}}' },
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: { title: [] },
});
});
test('params validation fails when body is not valid', () => {
const actionParams = {
subActionParams: { title: '' },
};
expect(actionTypeModel.validateParams(actionParams)).toEqual({
errors: {
title: ['Title is required.'],
},
});
});
});

View file

@ -0,0 +1,69 @@
/*
* 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 } from '../../../../types';
import { connectorConfiguration } from './config';
import logo from './logo.svg';
import { ResilientActionConnector, ResilientActionParams } from './types';
import * as i18n from './translations';
import { isValidUrl } from '../../../lib/value_validators';
const validateConnector = (action: ResilientActionConnector): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
apiUrl: new Array<string>(),
orgId: new Array<string>(),
apiKeyId: new Array<string>(),
apiKeySecret: new Array<string>(),
};
validationResult.errors = errors;
if (!action.config.apiUrl) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_REQUIRED];
}
if (action.config.apiUrl && !isValidUrl(action.config.apiUrl, 'https:')) {
errors.apiUrl = [...errors.apiUrl, i18n.API_URL_INVALID];
}
if (!action.config.orgId) {
errors.orgId = [...errors.orgId, i18n.ORG_ID_REQUIRED];
}
if (!action.secrets.apiKeyId) {
errors.apiKeyId = [...errors.apiKeyId, i18n.API_KEY_ID_REQUIRED];
}
if (!action.secrets.apiKeySecret) {
errors.apiKeySecret = [...errors.apiKeySecret, i18n.API_KEY_SECRET_REQUIRED];
}
return validationResult;
};
export function getActionType(): ActionTypeModel<ResilientActionConnector, ResilientActionParams> {
return {
id: connectorConfiguration.id,
iconClass: logo,
selectMessage: i18n.DESC,
actionTypeTitle: connectorConfiguration.name,
validateConnector,
actionConnectorFields: lazy(() => import('./resilient_connectors')),
validateParams: (actionParams: ResilientActionParams): ValidationResult => {
const validationResult = { errors: {} };
const errors = {
title: new Array<string>(),
};
validationResult.errors = errors;
if (actionParams.subActionParams && !actionParams.subActionParams.title?.length) {
errors.title.push(i18n.TITLE_REQUIRED);
}
return validationResult;
},
actionParamsFields: lazy(() => import('./resilient_params')),
};
}

View file

@ -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 React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { DocLinksStart } from 'kibana/public';
import ResilientConnectorFields from './resilient_connectors';
import { ResilientActionConnector } from './types';
describe('ResilientActionConnectorFields renders', () => {
test('alerting Resilient connector fields is rendered', () => {
const actionConnector = {
secrets: {
apiKeyId: 'key',
apiKeySecret: 'secret',
},
id: 'test',
actionTypeId: '.resilient',
isPreconfigured: false,
name: 'resilient',
config: {
apiUrl: 'https://test/',
orgId: '201',
},
} as ResilientActionConnector;
const deps = {
docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart,
};
const wrapper = mountWithIntl(
<ResilientConnectorFields
action={actionConnector}
errors={{ apiUrl: [], apiKeyId: [], apiKeySecret: [], orgId: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
docLinks={deps!.docLinks}
readOnly={false}
/>
);
expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy();
expect(
wrapper.find('[data-test-subj="connector-resilient-orgId-form-input"]').length > 0
).toBeTruthy();
expect(
wrapper.find('[data-test-subj="connector-resilient-apiKeySecret-form-input"]').length > 0
).toBeTruthy();
expect(
wrapper.find('[data-test-subj="connector-resilient-apiKeySecret-form-input"]').length > 0
).toBeTruthy();
});
test('case specific Resilient connector fields is rendered', () => {
const actionConnector = {
secrets: {
apiKeyId: 'email',
apiKeySecret: 'token',
},
id: 'test',
actionTypeId: '.resilient',
isPreconfigured: false,
name: 'resilient',
config: {
apiUrl: 'https://test/',
orgId: '201',
},
} as ResilientActionConnector;
const deps = {
docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart,
};
const wrapper = mountWithIntl(
<ResilientConnectorFields
action={actionConnector}
errors={{ apiUrl: [], apiKeyId: [], apiKeySecret: [], orgId: [] }}
editActionConfig={() => {}}
editActionSecrets={() => {}}
docLinks={deps!.docLinks}
readOnly={false}
consumer={'case'}
/>
);
expect(wrapper.find('[data-test-subj="case-resilient-mappings"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy();
expect(
wrapper.find('[data-test-subj="connector-resilient-orgId-form-input"]').length > 0
).toBeTruthy();
expect(
wrapper.find('[data-test-subj="connector-resilient-apiKeySecret-form-input"]').length > 0
).toBeTruthy();
expect(
wrapper.find('[data-test-subj="connector-resilient-apiKeySecret-form-input"]').length > 0
).toBeTruthy();
});
});

View file

@ -0,0 +1,209 @@
/*
* 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 } from 'react';
import {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiFieldPassword,
EuiSpacer,
} from '@elastic/eui';
import { isEmpty } from 'lodash';
import { ActionConnectorFieldsProps } from '../../../../types';
import * as i18n from './translations';
import { ResilientActionConnector } from './types';
import { connectorConfiguration } from './config';
import { FieldMapping, CasesConfigurationMapping, createDefaultMapping } from '../case_mappings';
const ResilientConnectorFields: React.FC<ActionConnectorFieldsProps<ResilientActionConnector>> = ({
action,
editActionSecrets,
editActionConfig,
errors,
consumer,
readOnly,
docLinks,
}) => {
// TODO: remove incidentConfiguration later, when Case Resilient will move their fields to the level of action execution
const { apiUrl, orgId, incidentConfiguration, isCaseOwned } = action.config;
const mapping = incidentConfiguration ? incidentConfiguration.mapping : [];
const isApiUrlInvalid: boolean = errors.apiUrl.length > 0 && apiUrl != null;
const { apiKeyId, apiKeySecret } = action.secrets;
const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId != null;
const isApiKeyInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId != null;
const isApiKeySecretInvalid: boolean = errors.apiKeySecret.length > 0 && apiKeySecret != null;
// TODO: remove this block later, when Case ServiceNow will move their fields to the level of action execution
if (consumer === 'case') {
if (isEmpty(mapping)) {
editActionConfig('incidentConfiguration', {
mapping: createDefaultMapping(connectorConfiguration.fields as any),
});
}
if (!isCaseOwned) {
editActionConfig('isCaseOwned', true);
}
}
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 (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="apiUrl"
fullWidth
error={errors.apiUrl}
isInvalid={isApiUrlInvalid}
label={i18n.API_URL_LABEL}
>
<EuiFieldText
fullWidth
isInvalid={isApiUrlInvalid}
name="apiUrl"
readOnly={readOnly}
value={apiUrl || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="apiUrlFromInput"
placeholder="https://<site-url>"
onChange={(evt) => handleOnChangeActionConfig('apiUrl', evt.target.value)}
onBlur={() => {
if (!apiUrl) {
editActionConfig('apiUrl', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="connector-resilient-orgId-key"
fullWidth
error={errors.orgId}
isInvalid={isOrgIdInvalid}
label={i18n.ORG_ID_LABEL}
>
<EuiFieldText
fullWidth
isInvalid={isOrgIdInvalid}
name="connector-resilient-orgId"
value={orgId || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="connector-resilient-orgId-form-input"
onChange={(evt) => handleOnChangeActionConfig('orgId', evt.target.value)}
onBlur={() => {
if (!orgId) {
editActionConfig('orgId', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="connector-resilient-apiKeyId"
fullWidth
error={errors.apiKeyId}
isInvalid={isApiKeyInvalid}
label={i18n.API_KEY_ID_LABEL}
>
<EuiFieldText
fullWidth
isInvalid={isApiKeyInvalid}
readOnly={readOnly}
name="connector-resilient-apiKeyId"
value={apiKeyId || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="connector-resilient-apiKeyId-form-input"
onChange={(evt) => handleOnChangeSecretConfig('apiKeyId', evt.target.value)}
onBlur={() => {
if (!apiKeyId) {
editActionSecrets('apiKeyId', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="connector-resilient-apiKeySecret"
fullWidth
error={errors.apiKeySecret}
isInvalid={isApiKeySecretInvalid}
label={i18n.API_KEY_SECRET_LABEL}
>
<EuiFieldPassword
fullWidth
readOnly={readOnly}
isInvalid={isApiKeySecretInvalid}
name="connector-resilient-apiKeySecret"
value={apiKeySecret || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="connector-resilient-apiKeySecret-form-input"
onChange={(evt) => handleOnChangeSecretConfig('apiKeySecret', evt.target.value)}
onBlur={() => {
if (!apiKeySecret) {
editActionSecrets('apiKeySecret', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
{consumer === 'case' && ( // TODO: remove this block later, when Case Resilient will move their fields to the level of action execution
<>
<EuiSpacer size="l" />
<EuiFlexGroup>
<EuiFlexItem data-test-subj="case-resilient-mappings">
<FieldMapping
disabled={true}
connectorConfiguration={connectorConfiguration}
mapping={mapping as CasesConfigurationMapping[]}
onChangeMapping={handleOnChangeMappingConfig}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
)}
</>
);
};
// eslint-disable-next-line import/no-default-export
export { ResilientConnectorFields as default };

View file

@ -0,0 +1,189 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import ResilientParamsFields from './resilient_params';
import { DocLinksStart } from 'kibana/public';
import { useGetIncidentTypes } from './use_get_incident_types';
import { useGetSeverity } from './use_get_severity';
jest.mock('../../../app_context', () => {
const post = jest.fn();
return {
useAppDependencies: jest.fn(() => ({ http: { post } })),
};
});
jest.mock('./use_get_incident_types');
jest.mock('./use_get_severity');
const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock;
const useGetSeverityMock = useGetSeverity as jest.Mock;
const actionParams = {
subAction: 'pushToService',
subActionParams: {
title: 'title',
description: 'some description',
comments: [{ commentId: '1', comment: 'comment for resilient' }],
incidentTypes: [1001],
severityCode: 6,
savedObjectId: '123',
externalId: null,
},
};
const connector = {
secrets: {},
config: {},
id: 'test',
actionTypeId: '.test',
name: 'Test',
isPreconfigured: false,
};
describe('ResilientParamsFields renders', () => {
const useGetIncidentTypesResponse = {
isLoading: false,
incidentTypes: [
{
id: 19,
name: 'Malware',
},
{
id: 21,
name: 'Denial of Service',
},
],
};
const useGetSeverityResponse = {
isLoading: false,
severity: [
{
id: 4,
name: 'Low',
},
{
id: 5,
name: 'Medium',
},
{
id: 6,
name: 'High',
},
],
};
beforeEach(() => {
useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse);
useGetSeverityMock.mockReturnValue(useGetSeverityResponse);
});
test('all params fields are rendered', () => {
const wrapper = mountWithIntl(
<ResilientParamsFields
actionParams={actionParams}
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
actionConnector={connector}
/>
);
expect(wrapper.find('[data-test-subj="incidentTypeComboBox"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toStrictEqual(
6
);
expect(wrapper.find('[data-test-subj="titleInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy();
});
test('it shows loading when loading incident types', () => {
useGetIncidentTypesMock.mockReturnValue({ ...useGetIncidentTypesResponse, isLoading: true });
const wrapper = mountWithIntl(
<ResilientParamsFields
actionParams={actionParams}
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
actionConnector={connector}
/>
);
expect(
wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('isLoading')
).toBeTruthy();
});
test('it shows loading when loading severity', () => {
useGetSeverityMock.mockReturnValue({
...useGetSeverityResponse,
isLoading: true,
});
const wrapper = mountWithIntl(
<ResilientParamsFields
actionParams={actionParams}
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
actionConnector={connector}
/>
);
expect(
wrapper.find('[data-test-subj="severitySelect"]').first().prop('isLoading')
).toBeTruthy();
});
test('it disabled the fields when loading issue types', () => {
useGetIncidentTypesMock.mockReturnValue({ ...useGetIncidentTypesResponse, isLoading: true });
const wrapper = mountWithIntl(
<ResilientParamsFields
actionParams={actionParams}
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
actionConnector={connector}
/>
);
expect(
wrapper.find('[data-test-subj="incidentTypeComboBox"]').first().prop('isDisabled')
).toBeTruthy();
});
test('it disabled the fields when loading severity', () => {
useGetSeverityMock.mockReturnValue({
...useGetSeverityResponse,
isLoading: true,
});
const wrapper = mountWithIntl(
<ResilientParamsFields
actionParams={actionParams}
errors={{ title: [] }}
editAction={() => {}}
index={0}
messageVariables={[]}
docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart}
actionConnector={connector}
/>
);
expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('disabled')).toBeTruthy();
});
});

View file

@ -0,0 +1,256 @@
/*
* 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, useState } from 'react';
import {
EuiFormRow,
EuiComboBox,
EuiSelect,
EuiSpacer,
EuiTitle,
EuiComboBoxOptionOption,
EuiSelectOption,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ActionParamsProps } from '../../../../types';
import { useAppDependencies } from '../../../app_context';
import { ResilientActionParams } from './types';
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
import { useGetIncidentTypes } from './use_get_incident_types';
import { useGetSeverity } from './use_get_severity';
const ResilientParamsFields: React.FunctionComponent<ActionParamsProps<ResilientActionParams>> = ({
actionParams,
editAction,
index,
errors,
messageVariables,
actionConnector,
}) => {
const [firstLoad, setFirstLoad] = useState(false);
const { http, toastNotifications } = useAppDependencies();
const { title, description, comments, incidentTypes, severityCode, savedObjectId } =
actionParams.subActionParams || {};
const [incidentTypesComboBoxOptions, setIncidentTypesComboBoxOptions] = useState<
Array<EuiComboBoxOptionOption<string>>
>([]);
const [selectedIncidentTypesComboBoxOptions, setSelectedIncidentTypesComboBoxOptions] = useState<
Array<EuiComboBoxOptionOption<string>>
>([]);
const [severitySelectOptions, setSeveritySelectOptions] = useState<EuiSelectOption[]>([]);
useEffect(() => {
setFirstLoad(true);
}, []);
const {
isLoading: isLoadingIncidentTypes,
incidentTypes: allIncidentTypes,
} = useGetIncidentTypes({
http,
toastNotifications,
actionConnector,
});
const { isLoading: isLoadingSeverity, severity } = useGetSeverity({
http,
toastNotifications,
actionConnector,
});
const editSubActionProperty = (key: string, value: {}) => {
const newProps = { ...actionParams.subActionParams, [key]: value };
editAction('subActionParams', newProps, index);
};
useEffect(() => {
const options = severity.map((s) => ({
value: s.id.toString(),
text: s.name,
}));
setSeveritySelectOptions(options);
}, [actionConnector, severity]);
// Reset parameters when changing connector
useEffect(() => {
if (!firstLoad) {
return;
}
setIncidentTypesComboBoxOptions([]);
setSelectedIncidentTypesComboBoxOptions([]);
setSeveritySelectOptions([]);
editAction('subActionParams', { title, comments, description: '', savedObjectId }, index);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionConnector]);
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', 'pushToService', index);
}
if (!savedObjectId && messageVariables?.find((variable) => variable.name === 'alertId')) {
editSubActionProperty('savedObjectId', '{{alertId}}');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionConnector, savedObjectId]);
useEffect(() => {
setIncidentTypesComboBoxOptions(
allIncidentTypes
? allIncidentTypes.map((type: { id: number; name: string }) => ({
label: type.name,
value: type.id.toString(),
}))
: []
);
const allIncidentTypesAsObject = allIncidentTypes.reduce(
(acc, type) => ({ ...acc, [type.id.toString()]: type.name }),
{} as Record<string, string>
);
setSelectedIncidentTypesComboBoxOptions(
incidentTypes
? incidentTypes
.map((type) => ({
label: allIncidentTypesAsObject[type.toString()],
value: type.toString(),
}))
.filter((type) => type.label != null)
: []
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionConnector, allIncidentTypes]);
return (
<Fragment>
<EuiTitle size="s">
<h3>Incident</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.urgencySelectFieldLabel',
{
defaultMessage: 'Incident Type',
}
)}
>
<EuiComboBox
fullWidth
isLoading={isLoadingIncidentTypes}
isDisabled={isLoadingIncidentTypes}
data-test-subj="incidentTypeComboBox"
options={incidentTypesComboBoxOptions}
selectedOptions={selectedIncidentTypesComboBoxOptions}
onChange={(selectedOptions: Array<{ label: string; value?: string }>) => {
setSelectedIncidentTypesComboBoxOptions(
selectedOptions.map((selectedOption) => ({
label: selectedOption.label,
value: selectedOption.value,
}))
);
editSubActionProperty(
'incidentTypes',
selectedOptions.map((selectedOption) => selectedOption.value ?? selectedOption.label)
);
}}
onBlur={() => {
if (!incidentTypes) {
editSubActionProperty('incidentTypes', []);
}
}}
isClearable={true}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.severity',
{
defaultMessage: 'Severity',
}
)}
>
<EuiSelect
isLoading={isLoadingSeverity}
disabled={isLoadingSeverity}
fullWidth
data-test-subj="severitySelect"
options={severitySelectOptions}
value={severityCode}
onChange={(e) => {
editSubActionProperty('severityCode', e.target.value);
}}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
error={errors.title}
isInvalid={errors.title.length > 0 && title !== undefined}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.titleFieldLabel',
{
defaultMessage: 'Name',
}
)}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'title'}
inputTargetValue={title}
errors={errors.title as string[]}
/>
</EuiFormRow>
<TextAreaWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'description'}
inputTargetValue={description}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.descriptionTextAreaFieldLabel',
{
defaultMessage: 'Description (optional)',
}
)}
errors={errors.description as string[]}
/>
<TextAreaWithMessageVariables
index={index}
editAction={(key, value) => {
editSubActionProperty(key, [{ commentId: 'alert-comment', comment: value }]);
}}
messageVariables={messageVariables}
paramsProperty={'comments'}
inputTargetValue={comments && comments.length > 0 ? comments[0].comment : ''}
label={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.commentsTextAreaFieldLabel',
{
defaultMessage: 'Additional comments (optional)',
}
)}
errors={errors.comments as string[]}
/>
</Fragment>
);
};
// eslint-disable-next-line import/no-default-export
export { ResilientParamsFields as default };

View file

@ -0,0 +1,133 @@
/*
* 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 DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.selectMessageText',
{
defaultMessage: 'Push or update data to a new incident in Resilient.',
}
);
export const TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.actionTypeTitle',
{
defaultMessage: 'Resilient',
}
);
export const API_URL_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.apiUrlTextFieldLabel',
{
defaultMessage: 'URL',
}
);
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 ORG_ID_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.orgId',
{
defaultMessage: 'Organization ID',
}
);
export const ORG_ID_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredOrgIdTextField',
{
defaultMessage: 'Organization ID is required',
}
);
export const API_KEY_ID_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.apiKeyId',
{
defaultMessage: 'API key ID',
}
);
export const API_KEY_ID_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.requiredApiKeyIdTextField',
{
defaultMessage: 'API key ID is required',
}
);
export const API_KEY_SECRET_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.apiKeySecret',
{
defaultMessage: 'API key secret',
}
);
export const 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.mappingFieldShortDescription',
{
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 DESCRIPTION_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.common.requiredDescriptionTextField',
{
defaultMessage: 'Description is required.',
}
);
export const TITLE_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.common.requiredTitleTextField',
{
defaultMessage: 'Title is required.',
}
);
export const INCIDENT_TYPES_API_ERROR = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.unableToGetIncidentTypesMessage',
{
defaultMessage: 'Unable to get incident types',
}
);
export const SEVERITY_API_ERROR = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.resilient.unableToGetSeverityMessage',
{
defaultMessage: 'Unable to get severity',
}
);

View file

@ -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 { CasesConfigurationMapping } from '../case_mappings';
export interface ResilientActionConnector {
config: ResilientConfig;
secrets: ResilientSecrets;
}
export interface ResilientActionParams {
subAction: string;
subActionParams: {
savedObjectId: string;
title: string;
description: string;
externalId: string | null;
incidentTypes: number[];
severityCode: number;
comments: Array<{ commentId: string; comment: string }>;
};
}
interface IncidentConfiguration {
mapping: CasesConfigurationMapping[];
}
interface ResilientConfig {
apiUrl: string;
orgId: string;
incidentConfiguration?: IncidentConfiguration;
isCaseOwned?: boolean;
}
interface ResilientSecrets {
apiKeyId: string;
apiKeySecret: string;
}

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useState, useEffect, useRef } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../../types';
import { getIncidentTypes } from './api';
import * as i18n from './translations';
type IncidentTypes = Array<{ id: number; name: string }>;
interface Props {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
actionConnector?: ActionConnector;
}
export interface UseGetIncidentTypes {
incidentTypes: IncidentTypes;
isLoading: boolean;
}
export const useGetIncidentTypes = ({
http,
toastNotifications,
actionConnector,
}: Props): UseGetIncidentTypes => {
const [isLoading, setIsLoading] = useState(true);
const [incidentTypes, setIncidentTypes] = useState<IncidentTypes>([]);
const abortCtrl = useRef(new AbortController());
useEffect(() => {
let didCancel = false;
const fetchData = async () => {
if (!actionConnector) {
setIsLoading(false);
return;
}
abortCtrl.current = new AbortController();
setIsLoading(true);
try {
const res = await getIncidentTypes({
http,
signal: abortCtrl.current.signal,
connectorId: actionConnector.id,
});
if (!didCancel) {
setIsLoading(false);
setIncidentTypes(res.data ?? []);
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.INCIDENT_TYPES_API_ERROR,
text: `${res.serviceMessage ?? res.message}`,
});
}
}
} catch (error) {
if (!didCancel) {
toastNotifications.addDanger({
title: i18n.INCIDENT_TYPES_API_ERROR,
text: error.message,
});
}
}
};
abortCtrl.current.abort();
fetchData();
return () => {
didCancel = true;
setIsLoading(false);
abortCtrl.current.abort();
};
}, [http, actionConnector, toastNotifications]);
return {
incidentTypes,
isLoading,
};
};

View file

@ -0,0 +1,91 @@
/*
* 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 { useState, useEffect, useRef } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../../types';
import { getSeverity } from './api';
import * as i18n from './translations';
type Severity = Array<{ id: number; name: string }>;
interface Props {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
actionConnector?: ActionConnector;
}
export interface UseGetSeverity {
severity: Severity;
isLoading: boolean;
}
export const useGetSeverity = ({
http,
toastNotifications,
actionConnector,
}: Props): UseGetSeverity => {
const [isLoading, setIsLoading] = useState(true);
const [severity, setSeverity] = useState<Severity>([]);
const abortCtrl = useRef(new AbortController());
useEffect(() => {
let didCancel = false;
const fetchData = async () => {
if (!actionConnector) {
setIsLoading(false);
return;
}
abortCtrl.current = new AbortController();
setIsLoading(true);
try {
const res = await getSeverity({
http,
signal: abortCtrl.current.signal,
connectorId: actionConnector.id,
});
if (!didCancel) {
setIsLoading(false);
setSeverity(res.data ?? []);
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.SEVERITY_API_ERROR,
text: `${res.serviceMessage ?? res.message}`,
});
}
}
} catch (error) {
if (!didCancel) {
toastNotifications.addDanger({
title: i18n.SEVERITY_API_ERROR,
text: error.message,
});
}
}
};
abortCtrl.current.abort();
fetchData();
return () => {
didCancel = true;
setIsLoading(false);
abortCtrl.current.abort();
};
}, [http, actionConnector, toastNotifications]);
return {
severity,
isLoading,
};
};

View file

@ -23,6 +23,7 @@ describe('ServiceNowParamsFields renders', () => {
externalId: null,
},
};
const wrapper = mountWithIntl(
<ServiceNowParamsFields
actionParams={actionParams}

View file

@ -13,6 +13,7 @@ describe('SlackParamsFields renders', () => {
const actionParams = {
message: 'test message',
};
const wrapper = mountWithIntl(
<SlackParamsFields
actionParams={actionParams}

View file

@ -13,6 +13,7 @@ describe('WebhookParamsFields renders', () => {
const actionParams = {
body: 'test message',
};
const wrapper = mountWithIntl(
<WebhookParamsFields
actionParams={actionParams}

View file

@ -8,3 +8,4 @@ export * from './expression_items';
export { connectorConfiguration as ServiceNowConnectorConfiguration } from '../application/components/builtin_action_types/servicenow/config';
export { connectorConfiguration as JiraConnectorConfiguration } from '../application/components/builtin_action_types/jira/config';
export { connectorConfiguration as ResilientConnectorConfiguration } from '../application/components/builtin_action_types/resilient/config';

View file

@ -0,0 +1,93 @@
/*
* 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 { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
getExternalServiceSimulatorPath,
ExternalServiceSimulator,
} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin';
const mapping = [
{
source: 'title',
target: 'description',
actionType: 'nothing',
},
{
source: 'description',
target: 'short_description',
actionType: 'nothing',
},
{
source: 'comments',
target: 'comments',
actionType: 'nothing',
},
];
// eslint-disable-next-line import/no-default-export
export default function resilientTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const mockResilient = {
config: {
apiUrl: 'www.resilientisinkibanaactions.com',
orgId: '201',
incidentConfiguration: { mapping: [...mapping] },
isCaseOwned: true,
},
secrets: {
apiKeyId: 'elastic',
apiKeySecret: 'changeme',
},
params: {
savedObjectId: '123',
title: 'a title',
description: 'a description',
incidentTypes: [1001],
severityCode: 'High',
comments: [
{
commentId: '456',
comment: 'first comment',
},
],
},
};
describe('resilient', () => {
let resilientSimulatorURL: string = '<could not determine kibana url>';
// need to wait for kibanaServer to settle ...
before(() => {
resilientSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.RESILIENT)
);
});
it('should return 403 when creating a resilient action', async () => {
await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'foo')
.send({
name: 'A resilient action',
actionTypeId: '.resilient',
config: {
apiUrl: resilientSimulatorURL,
incidentConfiguration: { ...mockResilient.config.incidentConfiguration },
isCaseOwned: true,
},
secrets: mockResilient.secrets,
})
.expect(403, {
statusCode: 403,
error: 'Forbidden',
message:
'Action type .resilient is disabled because your basic license does not support it. Please upgrade your license.',
});
});
});
}

View file

@ -41,9 +41,10 @@ export default function resilientTest({ getService }: FtrProviderContext) {
const mockResilient = {
config: {
apiUrl: 'www.jiraisinkibanaactions.com',
apiUrl: 'www.resilientisinkibanaactions.com',
orgId: '201',
casesConfiguration: { mapping },
incidentConfiguration: { mapping },
isCaseOwned: true,
},
secrets: {
apiKeyId: 'key',
@ -55,6 +56,8 @@ export default function resilientTest({ getService }: FtrProviderContext) {
savedObjectId: '123',
title: 'a title',
description: 'a description',
incidentTypes: [1001],
severityCode: 6,
createdAt: '2020-03-13T08:34:53.450Z',
createdBy: { fullName: 'Elastic User', username: 'elastic' },
updatedAt: null,
@ -108,7 +111,8 @@ export default function resilientTest({ getService }: FtrProviderContext) {
config: {
apiUrl: resilientSimulatorURL,
orgId: mockResilient.config.orgId,
casesConfiguration: mockResilient.config.casesConfiguration,
incidentConfiguration: mockResilient.config.incidentConfiguration,
isCaseOwned: true,
},
});
@ -124,7 +128,8 @@ export default function resilientTest({ getService }: FtrProviderContext) {
config: {
apiUrl: resilientSimulatorURL,
orgId: mockResilient.config.orgId,
casesConfiguration: mockResilient.config.casesConfiguration,
incidentConfiguration: mockResilient.config.incidentConfiguration,
isCaseOwned: true,
},
});
});
@ -179,7 +184,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
config: {
apiUrl: 'http://resilient.mynonexistent.com',
orgId: mockResilient.config.orgId,
casesConfiguration: mockResilient.config.casesConfiguration,
incidentConfiguration: mockResilient.config.incidentConfiguration,
},
secrets: mockResilient.secrets,
})
@ -204,7 +209,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
config: {
apiUrl: resilientSimulatorURL,
orgId: mockResilient.config.orgId,
casesConfiguration: mockResilient.config.casesConfiguration,
incidentConfiguration: mockResilient.config.incidentConfiguration,
},
})
.expect(400)
@ -218,30 +223,6 @@ export default function resilientTest({ getService }: FtrProviderContext) {
});
});
it('should respond with a 400 Bad Request when creating a ibm resilient action without casesConfiguration', async () => {
await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'foo')
.send({
name: 'An IBM Resilient',
actionTypeId: '.resilient',
config: {
apiUrl: resilientSimulatorURL,
orgId: mockResilient.config.orgId,
},
secrets: mockResilient.secrets,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: [casesConfiguration.mapping]: expected value of type [array] but got [undefined]',
});
});
});
it('should respond with a 400 Bad Request when creating a ibm resilient action with empty mapping', async () => {
await supertest
.post('/api/actions/action')
@ -252,7 +233,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
config: {
apiUrl: resilientSimulatorURL,
orgId: mockResilient.config.orgId,
casesConfiguration: { mapping: [] },
incidentConfiguration: { mapping: [] },
},
secrets: mockResilient.secrets,
})
@ -262,7 +243,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: [casesConfiguration.mapping]: expected non-empty but got empty',
'error validating action type config: [incidentConfiguration.mapping]: expected non-empty but got empty',
});
});
});
@ -277,7 +258,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
config: {
apiUrl: resilientSimulatorURL,
orgId: mockResilient.config.orgId,
casesConfiguration: {
incidentConfiguration: {
mapping: [
{
source: 'title',
@ -307,7 +288,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
config: {
apiUrl: resilientSimulatorURL,
orgId: mockResilient.config.orgId,
casesConfiguration: mockResilient.config.casesConfiguration,
incidentConfiguration: mockResilient.config.incidentConfiguration,
},
secrets: mockResilient.secrets,
});
@ -353,7 +334,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]',
});
});
});
@ -371,7 +352,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]',
});
});
});
@ -389,7 +370,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]',
});
});
});
@ -412,31 +393,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]',
});
});
});
it('should handle failing with a simulated success without createdAt', async () => {
await supertest
.post(`/api/actions/action/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockResilient.params,
subActionParams: {
savedObjectId: 'success',
title: 'success',
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
actionId: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.createdAt]: expected value of type [string] but got [undefined]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]',
});
});
});
@ -464,7 +421,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]',
});
});
});
@ -492,35 +449,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]',
});
});
});
it('should handle failing with a simulated success without comment.createdAt', async () => {
await supertest
.post(`/api/actions/action/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
...mockResilient.params,
subActionParams: {
...mockResilient.params.subActionParams,
savedObjectId: 'success',
title: 'success',
createdAt: 'success',
createdBy: { username: 'elastic' },
comments: [{ commentId: 'success', comment: 'success' }],
},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
actionId: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.createdAt]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]',
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [3.subAction]: expected value to equal [incidentTypes]\n- [4.subAction]: expected value to equal [severity]',
});
});
});
@ -536,7 +465,7 @@ export default function resilientTest({ getService }: FtrProviderContext) {
...mockResilient.params,
subActionParams: {
...mockResilient.params.subActionParams,
comments: [],
comments: null,
},
},
})

View file

@ -99,7 +99,7 @@ export const getResilientConnector = () => ({
config: {
apiUrl: 'http://some.non.existent.com',
orgId: 'pkey',
casesConfiguration: {
incidentConfiguration: {
mapping: [
{
source: 'title',
@ -118,6 +118,7 @@ export const getResilientConnector = () => ({
},
],
},
isCaseOwned: true,
},
});