diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts index 659ff14418d05..e77115ba4e228 100644 --- a/x-pack/plugins/cases/server/client/attachments/add.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.ts @@ -12,7 +12,6 @@ import { identity } from 'fp-ts/lib/function'; import { SavedObject, SavedObjectsClientContract, Logger } from 'src/core/server'; import { nodeBuilder } from '../../../../../../src/plugins/data/common'; -import { decodeCommentRequest, isCommentRequestTypeGenAlert } from '../../routes/api/utils'; import { throwErrors, @@ -33,7 +32,11 @@ import { } from '../../services/user_actions/helpers'; import { AttachmentService, CaseService, CaseUserActionService } from '../../services'; -import { CommentableCase, createAlertUpdateRequest } from '../../common'; +import { + CommentableCase, + createAlertUpdateRequest, + isCommentRequestTypeGenAlert, +} from '../../common'; import { CasesClientArgs, CasesClientInternal } from '..'; import { createCaseError } from '../../common/error'; import { @@ -42,6 +45,8 @@ import { ENABLE_CASE_CONNECTOR, } from '../../../common/constants'; +import { decodeCommentRequest } from '../utils'; + async function getSubCase({ caseService, savedObjectsClient, diff --git a/x-pack/plugins/cases/server/client/attachments/client.ts b/x-pack/plugins/cases/server/client/attachments/client.ts index f3ee3098a3153..27fb5e1cf61f0 100644 --- a/x-pack/plugins/cases/server/client/attachments/client.ts +++ b/x-pack/plugins/cases/server/client/attachments/client.ts @@ -5,18 +5,34 @@ * 2.0. */ -import { CaseResponse, CommentRequest as AttachmentsRequest } from '../../../common/api'; +import { + AllCommentsResponse, + CaseResponse, + CommentRequest as AttachmentsRequest, + CommentResponse, + CommentsResponse, +} from '../../../common/api'; + import { CasesClientInternal } from '../client_internal'; import { CasesClientArgs } from '../types'; import { addComment } from './add'; +import { DeleteAllArgs, deleteAll, DeleteArgs, deleteComment } from './delete'; +import { find, FindArgs, get, getAll, GetAllArgs, GetArgs } from './get'; +import { update, UpdateArgs } from './update'; -export interface AttachmentsAdd { +interface AttachmentsAdd { caseId: string; comment: AttachmentsRequest; } export interface AttachmentsSubClient { add(args: AttachmentsAdd): Promise; + deleteAll(deleteAllArgs: DeleteAllArgs): Promise; + delete(deleteArgs: DeleteArgs): Promise; + find(findArgs: FindArgs): Promise; + getAll(getAllArgs: GetAllArgs): Promise; + get(getArgs: GetArgs): Promise; + update(updateArgs: UpdateArgs): Promise; } export const createAttachmentsSubClient = ( @@ -31,6 +47,12 @@ export const createAttachmentsSubClient = ( caseId, comment, }), + deleteAll: (deleteAllArgs: DeleteAllArgs) => deleteAll(deleteAllArgs, args), + delete: (deleteArgs: DeleteArgs) => deleteComment(deleteArgs, args), + find: (findArgs: FindArgs) => find(findArgs, args), + getAll: (getAllArgs: GetAllArgs) => getAll(getAllArgs, args), + get: (getArgs: GetArgs) => get(getArgs, args), + update: (updateArgs: UpdateArgs) => update(updateArgs, args), }; return Object.freeze(attachmentSubClient); diff --git a/x-pack/plugins/cases/server/client/attachments/delete.ts b/x-pack/plugins/cases/server/client/attachments/delete.ts new file mode 100644 index 0000000000000..37069b94df7cb --- /dev/null +++ b/x-pack/plugins/cases/server/client/attachments/delete.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../common/constants'; + +import { AssociationType } from '../../../common/api'; +import { CasesClientArgs } from '../types'; +import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; +import { createCaseError } from '../../common/error'; +import { checkEnabledCaseConnectorOrThrow } from '../../common'; + +/** + * Parameters for deleting all comments of a case or sub case. + */ +export interface DeleteAllArgs { + caseID: string; + subCaseID?: string; +} + +/** + * Parameters for deleting a single comment of a case or sub case. + */ +export interface DeleteArgs { + caseID: string; + attachmentID: string; + subCaseID?: string; +} + +/** + * Delete all comments for a case or sub case. + */ +export async function deleteAll( + { caseID, subCaseID }: DeleteAllArgs, + clientArgs: CasesClientArgs +): Promise { + const { + user, + savedObjectsClient: soClient, + caseService, + attachmentService, + userActionService, + logger, + } = clientArgs; + + try { + checkEnabledCaseConnectorOrThrow(subCaseID); + + const id = subCaseID ?? caseID; + const comments = await caseService.getCommentsByAssociation({ + soClient, + id, + associationType: subCaseID ? AssociationType.subCase : AssociationType.case, + }); + + await Promise.all( + comments.saved_objects.map((comment) => + attachmentService.delete({ + soClient, + attachmentId: comment.id, + }) + ) + ); + + const deleteDate = new Date().toISOString(); + + await userActionService.bulkCreate({ + soClient, + actions: comments.saved_objects.map((comment) => + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: user, + caseId: caseID, + subCaseId: subCaseID, + commentId: comment.id, + fields: ['comment'], + }) + ), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to delete all comments case id: ${caseID} sub case id: ${subCaseID}: ${error}`, + error, + logger, + }); + } +} + +export async function deleteComment( + { caseID, attachmentID, subCaseID }: DeleteArgs, + clientArgs: CasesClientArgs +) { + const { + user, + savedObjectsClient: soClient, + attachmentService, + userActionService, + logger, + } = clientArgs; + + try { + checkEnabledCaseConnectorOrThrow(subCaseID); + + const deleteDate = new Date().toISOString(); + + const myComment = await attachmentService.get({ + soClient, + attachmentId: attachmentID, + }); + + if (myComment == null) { + throw Boom.notFound(`This comment ${attachmentID} does not exist anymore.`); + } + + const type = subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const id = subCaseID ?? caseID; + + const caseRef = myComment.references.find((c) => c.type === type); + if (caseRef == null || (caseRef != null && caseRef.id !== id)) { + throw Boom.notFound(`This comment ${attachmentID} does not exist in ${id}.`); + } + + await attachmentService.delete({ + soClient, + attachmentId: attachmentID, + }); + + await userActionService.bulkCreate({ + soClient, + actions: [ + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: user, + caseId: id, + subCaseId: subCaseID, + commentId: attachmentID, + fields: ['comment'], + }), + ], + }); + } catch (error) { + throw createCaseError({ + message: `Failed to delete comment in route case id: ${caseID} comment id: ${attachmentID} sub case id: ${subCaseID}: ${error}`, + error, + logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/attachments/get.ts b/x-pack/plugins/cases/server/client/attachments/get.ts new file mode 100644 index 0000000000000..70aeb5a3df2aa --- /dev/null +++ b/x-pack/plugins/cases/server/client/attachments/get.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import Boom from '@hapi/boom'; +import * as rt from 'io-ts'; +import { SavedObjectsFindResponse } from 'kibana/server'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; + +import { esKuery } from '../../../../../../src/plugins/data/server'; +import { + AllCommentsResponse, + AllCommentsResponseRt, + AssociationType, + CommentAttributes, + CommentResponse, + CommentResponseRt, + CommentsResponse, + CommentsResponseRt, + SavedObjectFindOptionsRt, +} from '../../../common/api'; +import { + checkEnabledCaseConnectorOrThrow, + defaultSortField, + transformComments, + flattenCommentSavedObject, + flattenCommentSavedObjects, +} from '../../common'; +import { createCaseError } from '../../common/error'; +import { defaultPage, defaultPerPage } from '../../routes/api'; +import { CasesClientArgs } from '../types'; + +const FindQueryParamsRt = rt.partial({ + ...SavedObjectFindOptionsRt.props, + subCaseId: rt.string, +}); + +type FindQueryParams = rt.TypeOf; + +export interface FindArgs { + caseID: string; + queryParams?: FindQueryParams; +} + +export interface GetAllArgs { + caseID: string; + includeSubCaseComments?: boolean; + subCaseID?: string; +} + +export interface GetArgs { + caseID: string; + attachmentID: string; +} + +/** + * Retrieves the attachments for a case entity. This support pagination. + */ +export async function find( + { caseID, queryParams }: FindArgs, + { savedObjectsClient: soClient, caseService, logger }: CasesClientArgs +): Promise { + try { + checkEnabledCaseConnectorOrThrow(queryParams?.subCaseId); + + const id = queryParams?.subCaseId ?? caseID; + const associationType = queryParams?.subCaseId ? AssociationType.subCase : AssociationType.case; + const { filter, ...queryWithoutFilter } = queryParams ?? {}; + const args = queryParams + ? { + caseService, + soClient, + id, + options: { + // We need this because the default behavior of getAllCaseComments is to return all the comments + // unless the page and/or perPage is specified. Since we're spreading the query after the request can + // still override this behavior. + page: defaultPage, + perPage: defaultPerPage, + sortField: 'created_at', + filter: filter != null ? esKuery.fromKueryExpression(filter) : filter, + ...queryWithoutFilter, + }, + associationType, + } + : { + caseService, + soClient, + id, + options: { + page: defaultPage, + perPage: defaultPerPage, + sortField: 'created_at', + }, + associationType, + }; + + const theComments = await caseService.getCommentsByAssociation(args); + return CommentsResponseRt.encode(transformComments(theComments)); + } catch (error) { + throw createCaseError({ + message: `Failed to find comments case id: ${caseID}: ${error}`, + error, + logger, + }); + } +} + +/** + * Retrieves a single attachment by its ID. + */ +export async function get( + { attachmentID, caseID }: GetArgs, + clientArgs: CasesClientArgs +): Promise { + const { attachmentService, savedObjectsClient: soClient, logger } = clientArgs; + + try { + const comment = await attachmentService.get({ + soClient, + attachmentId: attachmentID, + }); + + return CommentResponseRt.encode(flattenCommentSavedObject(comment)); + } catch (error) { + throw createCaseError({ + message: `Failed to get comment case id: ${caseID} attachment id: ${attachmentID}: ${error}`, + error, + logger, + }); + } +} + +/** + * Retrieves all the attachments for a case. The `includeSubCaseComments` can be used to include the sub case comments for + * collections. If the entity is a sub case, pass in the subCaseID. + */ +export async function getAll( + { caseID, includeSubCaseComments, subCaseID }: GetAllArgs, + clientArgs: CasesClientArgs +): Promise { + const { savedObjectsClient: soClient, caseService, logger } = clientArgs; + + try { + let comments: SavedObjectsFindResponse; + + if ( + !ENABLE_CASE_CONNECTOR && + (subCaseID !== undefined || includeSubCaseComments !== undefined) + ) { + throw Boom.badRequest( + 'The sub case id and include sub case comments fields are not supported when the case connector feature is disabled' + ); + } + + if (subCaseID) { + comments = await caseService.getAllSubCaseComments({ + soClient, + id: subCaseID, + options: { + sortField: defaultSortField, + }, + }); + } else { + comments = await caseService.getAllCaseComments({ + soClient, + id: caseID, + includeSubCaseComments, + options: { + sortField: defaultSortField, + }, + }); + } + + return AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)); + } catch (error) { + throw createCaseError({ + message: `Failed to get all comments case id: ${caseID} include sub case comments: ${includeSubCaseComments} sub case id: ${subCaseID}: ${error}`, + error, + logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/attachments/update.ts b/x-pack/plugins/cases/server/client/attachments/update.ts new file mode 100644 index 0000000000000..79b1f5bfc0225 --- /dev/null +++ b/x-pack/plugins/cases/server/client/attachments/update.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pick } from 'lodash/fp'; +import Boom from '@hapi/boom'; + +import { SavedObjectsClientContract, Logger } from 'kibana/server'; +import { checkEnabledCaseConnectorOrThrow, CommentableCase } from '../../common'; +import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../common/constants'; +import { AttachmentService, CaseService } from '../../services'; +import { CaseResponse, CommentPatchRequest } from '../../../common/api'; +import { CasesClientArgs } from '..'; +import { decodeCommentRequest } from '../utils'; +import { createCaseError } from '../../common/error'; + +export interface UpdateArgs { + caseID: string; + updateRequest: CommentPatchRequest; + subCaseID?: string; +} + +interface CombinedCaseParams { + attachmentService: AttachmentService; + caseService: CaseService; + soClient: SavedObjectsClientContract; + caseID: string; + logger: Logger; + subCaseId?: string; +} + +async function getCommentableCase({ + attachmentService, + caseService, + soClient, + caseID, + subCaseId, + logger, +}: CombinedCaseParams) { + if (subCaseId) { + const [caseInfo, subCase] = await Promise.all([ + caseService.getCase({ + soClient, + id: caseID, + }), + caseService.getSubCase({ + soClient, + id: subCaseId, + }), + ]); + return new CommentableCase({ + attachmentService, + caseService, + collection: caseInfo, + subCase, + soClient, + logger, + }); + } else { + const caseInfo = await caseService.getCase({ + soClient, + id: caseID, + }); + return new CommentableCase({ + attachmentService, + caseService, + collection: caseInfo, + soClient, + logger, + }); + } +} + +/** + * Update an attachment. + */ +export async function update( + { caseID, subCaseID, updateRequest: queryParams }: UpdateArgs, + clientArgs: CasesClientArgs +): Promise { + const { + attachmentService, + caseService, + savedObjectsClient: soClient, + logger, + user, + userActionService, + } = clientArgs; + + try { + checkEnabledCaseConnectorOrThrow(subCaseID); + + const { + id: queryCommentId, + version: queryCommentVersion, + ...queryRestAttributes + } = queryParams; + + decodeCommentRequest(queryRestAttributes); + + const commentableCase = await getCommentableCase({ + attachmentService, + caseService, + soClient, + caseID, + subCaseId: subCaseID, + logger, + }); + + const myComment = await attachmentService.get({ + soClient, + attachmentId: queryCommentId, + }); + + if (myComment == null) { + throw Boom.notFound(`This comment ${queryCommentId} does not exist anymore.`); + } + + if (myComment.attributes.type !== queryRestAttributes.type) { + throw Boom.badRequest(`You cannot change the type of the comment.`); + } + + const saveObjType = subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + + const caseRef = myComment.references.find((c) => c.type === saveObjType); + if (caseRef == null || (caseRef != null && caseRef.id !== commentableCase.id)) { + throw Boom.notFound( + `This comment ${queryCommentId} does not exist in ${commentableCase.id}).` + ); + } + + if (queryCommentVersion !== myComment.version) { + throw Boom.conflict( + 'This case has been updated. Please refresh before saving additional updates.' + ); + } + + const updatedDate = new Date().toISOString(); + const { + comment: updatedComment, + commentableCase: updatedCase, + } = await commentableCase.updateComment({ + updateRequest: queryParams, + updatedAt: updatedDate, + user, + }); + + await userActionService.bulkCreate({ + soClient, + actions: [ + buildCommentUserActionItem({ + action: 'update', + actionAt: updatedDate, + actionBy: user, + caseId: caseID, + subCaseId: subCaseID, + commentId: updatedComment.id, + fields: ['comment'], + newValue: JSON.stringify(queryRestAttributes), + oldValue: JSON.stringify( + // We are interested only in ContextBasicRt attributes + // myComment.attribute contains also CommentAttributesBasicRt attributes + pick(Object.keys(queryRestAttributes), myComment.attributes) + ), + }), + ], + }); + + return await updatedCase.encode(); + } catch (error) { + throw createCaseError({ + message: `Failed to patch comment case id: ${caseID} sub case id: ${subCaseID}: ${error}`, + error, + logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts index a77bfa01e6ec8..423863528184a 100644 --- a/x-pack/plugins/cases/server/client/cases/client.ts +++ b/x-pack/plugins/cases/server/client/cases/client.ts @@ -13,36 +13,47 @@ import { CasesResponse, CasesFindRequest, CasesFindResponse, + User, } from '../../../common/api'; import { CasesClient } from '../client'; import { CasesClientInternal } from '../client_internal'; import { CasesClientArgs } from '../types'; import { create } from './create'; +import { deleteCases } from './delete'; import { find } from './find'; -import { get } from './get'; +import { get, getReporters, getTags } from './get'; import { push } from './push'; import { update } from './update'; -export interface CaseGet { +interface CaseGet { id: string; includeComments?: boolean; includeSubCaseComments?: boolean; } -export interface CasePush { +interface CasePush { actionsClient: ActionsClient; caseId: string; connectorId: string; } +/** + * The public API for interacting with cases. + */ export interface CasesSubClient { create(theCase: CasePostRequest): Promise; find(args: CasesFindRequest): Promise; get(args: CaseGet): Promise; push(args: CasePush): Promise; update(args: CasesPatchRequest): Promise; + delete(ids: string[]): Promise; + getTags(): Promise; + getReporters(): Promise; } +/** + * Creates the interface for CRUD on cases objects. + */ export const createCasesSubClient = ( args: CasesClientArgs, casesClient: CasesClient, @@ -112,6 +123,9 @@ export const createCasesSubClient = ( casesClientInternal, logger, }), + delete: (ids: string[]) => deleteCases(ids, args), + getTags: () => getTags(args), + getReporters: () => getReporters(args), }; return Object.freeze(casesSubClient); diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 67496599d225d..d4c3ba5209583 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -17,8 +17,6 @@ import { SavedObjectsUtils, } from '../../../../../../src/core/server'; -import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; - import { throwErrors, excess, @@ -30,10 +28,7 @@ import { User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; -import { - getConnectorFromConfiguration, - transformCaseConnectorToEsConnector, -} from '../../routes/api/cases/helpers'; +import { getConnectorFromConfiguration } from '../utils'; import { CaseConfigureService, CaseService, CaseUserActionService } from '../../services'; import { createCaseError } from '../../common/error'; @@ -41,7 +36,12 @@ import { Authorization } from '../../authorization/authorization'; import { Operations } from '../../authorization'; import { AuditLogger, EventOutcome } from '../../../../security/server'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; -import { createAuditMsg } from '../../common'; +import { + createAuditMsg, + flattenCaseSavedObject, + transformCaseConnectorToEsConnector, + transformNewCase, +} from '../../common'; interface CreateCaseArgs { caseConfigureService: CaseConfigureService; diff --git a/x-pack/plugins/cases/server/client/cases/delete.ts b/x-pack/plugins/cases/server/client/cases/delete.ts new file mode 100644 index 0000000000000..1bc94b5a0b4c8 --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/delete.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; +import { CasesClientArgs } from '..'; +import { createCaseError } from '../../common/error'; +import { AttachmentService, CaseService } from '../../services'; +import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; + +async function deleteSubCases({ + attachmentService, + caseService, + soClient, + caseIds, +}: { + attachmentService: AttachmentService; + caseService: CaseService; + soClient: SavedObjectsClientContract; + caseIds: string[]; +}) { + const subCasesForCaseIds = await caseService.findSubCasesByCaseId({ soClient, ids: caseIds }); + + const subCaseIDs = subCasesForCaseIds.saved_objects.map((subCase) => subCase.id); + const commentsForSubCases = await caseService.getAllSubCaseComments({ + soClient, + id: subCaseIDs, + }); + + // This shouldn't actually delete anything because all the comments should be deleted when comments are deleted + // per case ID + await Promise.all( + commentsForSubCases.saved_objects.map((commentSO) => + attachmentService.delete({ soClient, attachmentId: commentSO.id }) + ) + ); + + await Promise.all( + subCasesForCaseIds.saved_objects.map((subCaseSO) => + caseService.deleteSubCase(soClient, subCaseSO.id) + ) + ); +} + +export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): Promise { + const { + savedObjectsClient: soClient, + caseService, + attachmentService, + user, + userActionService, + logger, + } = clientArgs; + try { + await Promise.all( + ids.map((id) => + caseService.deleteCase({ + soClient, + id, + }) + ) + ); + const comments = await Promise.all( + ids.map((id) => + caseService.getAllCaseComments({ + soClient, + id, + }) + ) + ); + + if (comments.some((c) => c.saved_objects.length > 0)) { + await Promise.all( + comments.map((c) => + Promise.all( + c.saved_objects.map(({ id }) => + attachmentService.delete({ + soClient, + attachmentId: id, + }) + ) + ) + ) + ); + } + + if (ENABLE_CASE_CONNECTOR) { + await deleteSubCases({ + attachmentService, + caseService, + soClient, + caseIds: ids, + }); + } + + const deleteDate = new Date().toISOString(); + + await userActionService.bulkCreate({ + soClient, + actions: ids.map((id) => + buildCaseUserActionItem({ + action: 'create', + actionAt: deleteDate, + actionBy: user, + caseId: id, + fields: [ + 'comment', + 'description', + 'status', + 'tags', + 'title', + ...(ENABLE_CASE_CONNECTOR ? ['sub_case'] : []), + ], + }) + ), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to delete cases ids: ${JSON.stringify(ids)}: ${error}`, + error, + logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/cases/find.ts b/x-pack/plugins/cases/server/client/cases/find.ts index aebecb821b449..b3c201f65f212 100644 --- a/x-pack/plugins/cases/server/client/cases/find.ts +++ b/x-pack/plugins/cases/server/client/cases/find.ts @@ -25,13 +25,12 @@ import { import { CASE_SAVED_OBJECT } from '../../../common/constants'; import { CaseService } from '../../services'; import { createCaseError } from '../../common/error'; -import { constructQueryOptions } from '../../routes/api/cases/helpers'; -import { transformCases } from '../../routes/api/utils'; +import { constructQueryOptions } from '../utils'; import { Authorization } from '../../authorization/authorization'; import { includeFieldsRequiredForAuthentication } from '../../authorization/utils'; import { AuthorizationFilter, Operations } from '../../authorization'; import { AuditLogger } from '../../../../security/server'; -import { createAuditMsg } from '../../common'; +import { createAuditMsg, transformCases } from '../../common'; interface FindParams { savedObjectsClient: SavedObjectsClientContract; diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index ccef35007118f..58fff0d5e435d 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -4,14 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import Boom from '@hapi/boom'; import { SavedObjectsClientContract, Logger, SavedObject } from 'kibana/server'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; -import { CaseResponseRt, CaseResponse, ESCaseAttributes } from '../../../common/api'; +import { CaseResponseRt, CaseResponse, ESCaseAttributes, User, UsersRt } from '../../../common/api'; import { CaseService } from '../../services'; -import { countAlertsForID } from '../../common'; +import { countAlertsForID, flattenCaseSavedObject } from '../../common'; import { createCaseError } from '../../common/error'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; +import { CasesClientArgs } from '..'; interface GetParams { savedObjectsClient: SavedObjectsClientContract; @@ -34,6 +35,12 @@ export const get = async ({ includeSubCaseComments = false, }: GetParams): Promise => { try { + if (!ENABLE_CASE_CONNECTOR && includeSubCaseComments) { + throw Boom.badRequest( + 'The `includeSubCaseComments` is not supported when the case connector feature is disabled' + ); + } + let theCase: SavedObject; let subCaseIds: string[] = []; @@ -86,3 +93,38 @@ export const get = async ({ throw createCaseError({ message: `Failed to get case id: ${id}: ${error}`, error, logger }); } }; + +/** + * Retrieves the tags from all the cases. + */ +export async function getTags({ + savedObjectsClient: soClient, + caseService, + logger, +}: CasesClientArgs): Promise { + try { + return await caseService.getTags({ + soClient, + }); + } catch (error) { + throw createCaseError({ message: `Failed to get tags: ${error}`, error, logger }); + } +} + +/** + * Retrieves the reporters from all the cases. + */ +export async function getReporters({ + savedObjectsClient: soClient, + caseService, + logger, +}: CasesClientArgs): Promise { + try { + const reporters = await caseService.getReporters({ + soClient, + }); + return UsersRt.encode(reporters); + } catch (error) { + throw createCaseError({ message: `Failed to get reporters: ${error}`, error, logger }); + } +} diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index c2c4d11da991d..ae690c8b6a086 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -15,7 +15,6 @@ import { SavedObject, } from 'kibana/server'; import { ActionResult, ActionsClient } from '../../../../actions/server'; -import { flattenCaseSavedObject, getAlertInfoFromComments } from '../../routes/api/utils'; import { ActionConnector, @@ -39,7 +38,7 @@ import { CaseUserActionService, AttachmentService, } from '../../services'; -import { createCaseError } from '../../common/error'; +import { createCaseError, flattenCaseSavedObject, getAlertInfoFromComments } from '../../common'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; import { CasesClient, CasesClientInternal } from '..'; @@ -134,7 +133,6 @@ export const push = async ({ try { connectorMappings = await casesClientInternal.configuration.getMappings({ - actionsClient, connectorId: connector.id, connectorType: connector.actionTypeId, }); diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index 52674e4c1b461..dcd66ebbcae26 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -19,10 +19,6 @@ import { } from 'kibana/server'; import { nodeBuilder } from '../../../../../../src/plugins/data/common'; -import { - flattenCaseSavedObject, - isCommentRequestTypeAlertOrGenAlert, -} from '../../routes/api/utils'; import { throwErrors, @@ -42,10 +38,7 @@ import { User, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; -import { - getCaseToUpdate, - transformCaseConnectorToEsConnector, -} from '../../routes/api/cases/helpers'; +import { getCaseToUpdate } from '../utils'; import { CaseService, CaseUserActionService } from '../../services'; import { @@ -53,7 +46,12 @@ import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT, } from '../../../common/constants'; -import { createAlertUpdateRequest } from '../../common'; +import { + createAlertUpdateRequest, + transformCaseConnectorToEsConnector, + flattenCaseSavedObject, + isCommentRequestTypeAlertOrGenAlert, +} from '../../common'; import { createCaseError } from '../../common/error'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; import { UpdateAlertRequest } from '../alerts/client'; diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index 859114a5e8fb0..5f41a95d3c501 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -6,7 +6,6 @@ */ import { actionsClientMock } from '../../../../actions/server/actions_client.mock'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; import { mockCases } from '../../routes/api/__fixtures__'; import { BasicParams, ExternalServiceParams, Incident } from './types'; @@ -29,6 +28,7 @@ import { transformers, transformFields, } from './utils'; +import { flattenCaseSavedObject } from '../../common'; const formatComment = { commentId: commentObj.id, diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index 7e77bf4ac84cc..8bac4956a9e5f 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -38,7 +38,7 @@ import { TransformerArgs, TransformFieldsArgs, } from './types'; -import { getAlertIds } from '../../routes/api/utils'; +import { getAlertIds } from '../utils'; interface CreateIncidentArgs { actionsClient: ActionsClient; diff --git a/x-pack/plugins/cases/server/client/client.ts b/x-pack/plugins/cases/server/client/client.ts index cb2201b8721f2..9d0da7018518f 100644 --- a/x-pack/plugins/cases/server/client/client.ts +++ b/x-pack/plugins/cases/server/client/client.ts @@ -12,6 +12,8 @@ import { UserActionsSubClient, createUserActionsSubClient } from './user_actions import { CasesClientInternal, createCasesClientInternal } from './client_internal'; import { createSubCasesClient, SubCasesClient } from './sub_cases/client'; import { ENABLE_CASE_CONNECTOR } from '../../common/constants'; +import { ConfigureSubClient, createConfigurationSubClient } from './configure/client'; +import { createStatsSubClient, StatsSubClient } from './stats/client'; export class CasesClient { private readonly _casesClientInternal: CasesClientInternal; @@ -19,13 +21,17 @@ export class CasesClient { private readonly _attachments: AttachmentsSubClient; private readonly _userActions: UserActionsSubClient; private readonly _subCases: SubCasesClient; + private readonly _configure: ConfigureSubClient; + private readonly _stats: StatsSubClient; constructor(args: CasesClientArgs) { this._casesClientInternal = createCasesClientInternal(args); this._cases = createCasesSubClient(args, this, this._casesClientInternal); this._attachments = createAttachmentsSubClient(args, this._casesClientInternal); this._userActions = createUserActionsSubClient(args); - this._subCases = createSubCasesClient(args, this); + this._subCases = createSubCasesClient(args, this._casesClientInternal); + this._configure = createConfigurationSubClient(args, this._casesClientInternal); + this._stats = createStatsSubClient(args); } public get cases() { @@ -47,9 +53,12 @@ export class CasesClient { return this._subCases; } - // TODO: Remove it when all routes will be moved to the cases client. - public get casesClientInternal() { - return this._casesClientInternal; + public get configure() { + return this._configure; + } + + public get stats() { + return this._stats; } } diff --git a/x-pack/plugins/cases/server/client/client_internal.ts b/x-pack/plugins/cases/server/client/client_internal.ts index 79f107e17af35..3623498223da7 100644 --- a/x-pack/plugins/cases/server/client/client_internal.ts +++ b/x-pack/plugins/cases/server/client/client_internal.ts @@ -7,15 +7,18 @@ import { CasesClientArgs } from './types'; import { AlertSubClient, createAlertsSubClient } from './alerts/client'; -import { ConfigureSubClient, createConfigurationSubClient } from './configure/client'; +import { + InternalConfigureSubClient, + createInternalConfigurationSubClient, +} from './configure/client'; export class CasesClientInternal { private readonly _alerts: AlertSubClient; - private readonly _configuration: ConfigureSubClient; + private readonly _configuration: InternalConfigureSubClient; constructor(args: CasesClientArgs) { this._alerts = createAlertsSubClient(args); - this._configuration = createConfigurationSubClient(args, this); + this._configuration = createInternalConfigurationSubClient(args, this); } public get alerts() { diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index 8ea91415fd163..2b9048a4518e9 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -4,39 +4,71 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import Boom from '@hapi/boom'; -import { ActionsClient } from '../../../../actions/server'; -import { ConnectorMappingsAttributes, GetFieldsResponse } from '../../../common/api'; +import { SUPPORTED_CONNECTORS } from '../../../common/constants'; +import { + CaseConfigureResponseRt, + CasesConfigurePatch, + CasesConfigureRequest, + CasesConfigureResponse, + ConnectorMappingsAttributes, + GetFieldsResponse, +} from '../../../common/api'; +import { createCaseError } from '../../common/error'; +import { + transformCaseConnectorToEsConnector, + transformESConnectorToCaseConnector, +} from '../../common'; import { CasesClientInternal } from '../client_internal'; import { CasesClientArgs } from '../types'; import { getFields } from './get_fields'; import { getMappings } from './get_mappings'; -export interface ConfigurationGetFields { - actionsClient: ActionsClient; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { FindActionResult } from '../../../../actions/server/types'; +import { ActionType } from '../../../../actions/common'; + +interface ConfigurationGetFields { connectorId: string; connectorType: string; } -export interface ConfigurationGetMappings { - actionsClient: ActionsClient; +interface ConfigurationGetMappings { connectorId: string; connectorType: string; } -export interface ConfigureSubClient { +/** + * Defines the internal helper functions. + */ +export interface InternalConfigureSubClient { getFields(args: ConfigurationGetFields): Promise; getMappings(args: ConfigurationGetMappings): Promise; } -export const createConfigurationSubClient = ( +/** + * This is the public API for interacting with the connector configuration for cases. + */ +export interface ConfigureSubClient { + get(): Promise; + getConnectors(): Promise; + update(configurations: CasesConfigurePatch): Promise; + create(configuration: CasesConfigureRequest): Promise; +} + +/** + * These functions should not be exposed on the plugin contract. They are for internal use to support the CRUD of + * configurations. + */ +export const createInternalConfigurationSubClient = ( args: CasesClientArgs, casesClientInternal: CasesClientInternal -): ConfigureSubClient => { - const { savedObjectsClient, connectorMappingsService, logger } = args; +): InternalConfigureSubClient => { + const { savedObjectsClient, connectorMappingsService, logger, actionsClient } = args; - const configureSubClient: ConfigureSubClient = { - getFields: (fields: ConfigurationGetFields) => getFields(fields), + const configureSubClient: InternalConfigureSubClient = { + getFields: (fields: ConfigurationGetFields) => getFields({ ...fields, actionsClient }), getMappings: (params: ConfigurationGetMappings) => getMappings({ ...params, @@ -49,3 +81,209 @@ export const createConfigurationSubClient = ( return Object.freeze(configureSubClient); }; + +export const createConfigurationSubClient = ( + clientArgs: CasesClientArgs, + casesInternalClient: CasesClientInternal +): ConfigureSubClient => { + return Object.freeze({ + get: () => get(clientArgs, casesInternalClient), + getConnectors: () => getConnectors(clientArgs), + update: (configuration: CasesConfigurePatch) => + update(configuration, clientArgs, casesInternalClient), + create: (configuration: CasesConfigureRequest) => + create(configuration, clientArgs, casesInternalClient), + }); +}; + +async function get( + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise { + const { savedObjectsClient: soClient, caseConfigureService, logger } = clientArgs; + try { + let error: string | null = null; + + const myCaseConfigure = await caseConfigureService.find({ soClient }); + + const { connector, ...caseConfigureWithoutConnector } = myCaseConfigure.saved_objects[0] + ?.attributes ?? { connector: null }; + let mappings: ConnectorMappingsAttributes[] = []; + if (connector != null) { + try { + mappings = await casesClientInternal.configuration.getMappings({ + connectorId: connector.id, + connectorType: connector.type, + }); + } catch (e) { + error = e.isBoom + ? e.output.payload.message + : `Error connecting to ${connector.name} instance`; + } + } + + return myCaseConfigure.saved_objects.length > 0 + ? CaseConfigureResponseRt.encode({ + ...caseConfigureWithoutConnector, + connector: transformESConnectorToCaseConnector(connector), + mappings, + version: myCaseConfigure.saved_objects[0].version ?? '', + error, + }) + : {}; + } catch (error) { + throw createCaseError({ message: `Failed to get case configure: ${error}`, error, logger }); + } +} + +async function getConnectors({ + actionsClient, + logger, +}: CasesClientArgs): Promise { + const isConnectorSupported = ( + action: FindActionResult, + actionTypes: Record + ): boolean => + SUPPORTED_CONNECTORS.includes(action.actionTypeId) && + actionTypes[action.actionTypeId]?.enabledInLicense; + + try { + const actionTypes = (await actionsClient.listTypes()).reduce( + (types, type) => ({ ...types, [type.id]: type }), + {} + ); + + return (await actionsClient.getAll()).filter((action) => + isConnectorSupported(action, actionTypes) + ); + } catch (error) { + throw createCaseError({ message: `Failed to get connectors: ${error}`, error, logger }); + } +} + +async function update( + configurations: CasesConfigurePatch, + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise { + const { caseConfigureService, logger, savedObjectsClient: soClient, user } = clientArgs; + + try { + let error = null; + + const myCaseConfigure = await caseConfigureService.find({ soClient }); + const { version, connector, ...queryWithoutVersion } = configurations; + if (myCaseConfigure.saved_objects.length === 0) { + throw Boom.conflict( + 'You can not patch this configuration since you did not created first with a post.' + ); + } + + if (version !== myCaseConfigure.saved_objects[0].version) { + throw Boom.conflict( + 'This configuration has been updated. Please refresh before saving additional updates.' + ); + } + + const updateDate = new Date().toISOString(); + + let mappings: ConnectorMappingsAttributes[] = []; + if (connector != null) { + try { + mappings = await casesClientInternal.configuration.getMappings({ + connectorId: connector.id, + connectorType: connector.type, + }); + } catch (e) { + error = e.isBoom + ? e.output.payload.message + : `Error connecting to ${connector.name} instance`; + } + } + const patch = await caseConfigureService.patch({ + soClient, + caseConfigureId: myCaseConfigure.saved_objects[0].id, + updatedAttributes: { + ...queryWithoutVersion, + ...(connector != null ? { connector: transformCaseConnectorToEsConnector(connector) } : {}), + updated_at: updateDate, + updated_by: user, + }, + }); + return CaseConfigureResponseRt.encode({ + ...myCaseConfigure.saved_objects[0].attributes, + ...patch.attributes, + connector: transformESConnectorToCaseConnector( + patch.attributes.connector ?? myCaseConfigure.saved_objects[0].attributes.connector + ), + mappings, + version: patch.version ?? '', + error, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get patch configure in route: ${error}`, + error, + logger, + }); + } +} + +async function create( + configuration: CasesConfigureRequest, + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise { + const { savedObjectsClient: soClient, caseConfigureService, logger, user } = clientArgs; + try { + let error = null; + + const myCaseConfigure = await caseConfigureService.find({ soClient }); + if (myCaseConfigure.saved_objects.length > 0) { + await Promise.all( + myCaseConfigure.saved_objects.map((cc) => + caseConfigureService.delete({ soClient, caseConfigureId: cc.id }) + ) + ); + } + + const creationDate = new Date().toISOString(); + let mappings: ConnectorMappingsAttributes[] = []; + try { + mappings = await casesClientInternal.configuration.getMappings({ + connectorId: configuration.connector.id, + connectorType: configuration.connector.type, + }); + } catch (e) { + error = e.isBoom + ? e.output.payload.message + : `Error connecting to ${configuration.connector.name} instance`; + } + const post = await caseConfigureService.post({ + soClient, + attributes: { + ...configuration, + connector: transformCaseConnectorToEsConnector(configuration.connector), + created_at: creationDate, + created_by: user, + updated_at: null, + updated_by: null, + }, + }); + + return CaseConfigureResponseRt.encode({ + ...post.attributes, + // Reserve for future implementations + connector: transformESConnectorToCaseConnector(post.attributes.connector), + mappings, + version: post.version ?? '', + error, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to create case configuration: ${error}`, + error, + logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/configure/get_fields.ts b/x-pack/plugins/cases/server/client/configure/get_fields.ts index 799f50845dda6..8a6b20256328f 100644 --- a/x-pack/plugins/cases/server/client/configure/get_fields.ts +++ b/x-pack/plugins/cases/server/client/configure/get_fields.ts @@ -6,11 +6,18 @@ */ import Boom from '@hapi/boom'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { ActionsClient } from '../../../../actions/server'; import { GetFieldsResponse } from '../../../common/api'; -import { ConfigurationGetFields } from './client'; import { createDefaultMapping, formatFields } from './utils'; +interface ConfigurationGetFields { + connectorId: string; + connectorType: string; + actionsClient: PublicMethodsOf; +} + export const getFields = async ({ actionsClient, connectorType, diff --git a/x-pack/plugins/cases/server/client/configure/get_mappings.ts b/x-pack/plugins/cases/server/client/configure/get_mappings.ts index c157252909f66..4f8b8c6cbf32a 100644 --- a/x-pack/plugins/cases/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/cases/server/client/configure/get_mappings.ts @@ -6,7 +6,6 @@ */ import { SavedObjectsClientContract, Logger } from 'src/core/server'; -import { ActionsClient } from '../../../../actions/server'; import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects'; @@ -17,7 +16,6 @@ import { createCaseError } from '../../common/error'; interface GetMappingsArgs { savedObjectsClient: SavedObjectsClientContract; connectorMappingsService: ConnectorMappingsService; - actionsClient: ActionsClient; casesClientInternal: CasesClientInternal; connectorType: string; connectorId: string; @@ -27,7 +25,6 @@ interface GetMappingsArgs { export const getMappings = async ({ savedObjectsClient, connectorMappingsService, - actionsClient, casesClientInternal, connectorType, connectorId, @@ -50,7 +47,6 @@ export const getMappings = async ({ // Create connector mappings if there are none if (myConnectorMappings.total === 0) { const res = await casesClientInternal.configuration.getFields({ - actionsClient, connectorId, connectorType, }); diff --git a/x-pack/plugins/cases/server/client/factory.ts b/x-pack/plugins/cases/server/client/factory.ts index 87a2b9583dac0..1202fe8c2a421 100644 --- a/x-pack/plugins/cases/server/client/factory.ts +++ b/x-pack/plugins/cases/server/client/factory.ts @@ -24,6 +24,7 @@ import { AttachmentService, } from '../services'; import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; +import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { AuthorizationAuditLogger } from '../authorization'; import { CasesClient, createCasesClient } from '.'; @@ -38,6 +39,7 @@ interface CasesClientFactoryArgs { securityPluginStart?: SecurityPluginStart; getSpace: GetSpaceFn; featuresPluginStart: FeaturesPluginStart; + actionsPluginStart: ActionsPluginStart; } /** @@ -95,7 +97,7 @@ export class CasesClientFactory { auditLogger: new AuthorizationAuditLogger(auditLogger), }); - const user = this.options.caseService.getUser({ request }); + const userInfo = this.options.caseService.getUser({ request }); return createCasesClient({ alertsService: this.options.alertsService, @@ -103,7 +105,8 @@ export class CasesClientFactory { savedObjectsClient: savedObjectsService.getScopedClient(request, { includedHiddenTypes: SAVED_OBJECT_TYPES, }), - user, + // We only want these fields from the userInfo object + user: { username: userInfo.username, email: userInfo.email, full_name: userInfo.full_name }, caseService: this.options.caseService, caseConfigureService: this.options.caseConfigureService, connectorMappingsService: this.options.connectorMappingsService, @@ -112,6 +115,7 @@ export class CasesClientFactory { logger: this.logger, authorization: auth, auditLogger, + actionsClient: await this.options.actionsPluginStart.getActionsClientWithRequest(request), }); } } diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index 03ad31fc2c1bb..7db3d62c491e7 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -7,10 +7,12 @@ import { PublicContract, PublicMethodsOf } from '@kbn/utility-types'; -import { CasesClient, CasesClientInternal } from '.'; +import { CasesClient } from '.'; import { AttachmentsSubClient } from './attachments/client'; import { CasesSubClient } from './cases/client'; +import { ConfigureSubClient } from './configure/client'; import { CasesClientFactory } from './factory'; +import { StatsSubClient } from './stats/client'; import { SubCasesClient } from './sub_cases/client'; import { UserActionsSubClient } from './user_actions/client'; @@ -23,6 +25,9 @@ const createCasesSubClientMock = (): CasesSubClientMock => { get: jest.fn(), push: jest.fn(), update: jest.fn(), + delete: jest.fn(), + getTags: jest.fn(), + getReporters: jest.fn(), }; }; @@ -31,6 +36,12 @@ type AttachmentsSubClientMock = jest.Mocked; const createAttachmentsSubClientMock = (): AttachmentsSubClientMock => { return { add: jest.fn(), + deleteAll: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + getAll: jest.fn(), + get: jest.fn(), + update: jest.fn(), }; }; @@ -53,7 +64,24 @@ const createSubCasesClientMock = (): SubCasesClientMock => { }; }; -type CasesClientInternalMock = jest.Mocked; +type ConfigureSubClientMock = jest.Mocked; + +const createConfigureSubClientMock = (): ConfigureSubClientMock => { + return { + get: jest.fn(), + getConnectors: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }; +}; + +type StatsSubClientMock = jest.Mocked; + +const createStatsSubClientMock = (): StatsSubClientMock => { + return { + getStatusTotalsByType: jest.fn(), + }; +}; export interface CasesClientMock extends CasesClient { cases: CasesSubClientMock; @@ -64,11 +92,12 @@ export interface CasesClientMock extends CasesClient { export const createCasesClientMock = (): CasesClientMock => { const client: PublicContract = { - casesClientInternal: (jest.fn() as unknown) as CasesClientInternalMock, cases: createCasesSubClientMock(), attachments: createAttachmentsSubClientMock(), userActions: createUserActionsSubClientMock(), subCases: createSubCasesClientMock(), + configure: createConfigureSubClientMock(), + stats: createStatsSubClientMock(), }; return (client as unknown) as CasesClientMock; }; diff --git a/x-pack/plugins/cases/server/client/stats/client.ts b/x-pack/plugins/cases/server/client/stats/client.ts new file mode 100644 index 0000000000000..40ced0bfbf4bb --- /dev/null +++ b/x-pack/plugins/cases/server/client/stats/client.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CasesClientArgs } from '..'; +import { CasesStatusResponse, CasesStatusResponseRt, caseStatuses } from '../../../common/api'; +import { createCaseError } from '../../common/error'; +import { constructQueryOptions } from '../utils'; + +/** + * Statistics API contract. + */ +export interface StatsSubClient { + getStatusTotalsByType(): Promise; +} + +/** + * Creates the interface for retrieving the number of open, closed, and in progress cases. + */ +export function createStatsSubClient(clientArgs: CasesClientArgs): StatsSubClient { + return Object.freeze({ + getStatusTotalsByType: () => getStatusTotalsByType(clientArgs), + }); +} + +async function getStatusTotalsByType({ + savedObjectsClient: soClient, + caseService, + logger, +}: CasesClientArgs): Promise { + try { + const [openCases, inProgressCases, closedCases] = await Promise.all([ + ...caseStatuses.map((status) => { + const statusQuery = constructQueryOptions({ status }); + return caseService.findCaseStatusStats({ + soClient, + caseOptions: statusQuery.case, + subCaseOptions: statusQuery.subCase, + }); + }), + ]); + + return CasesStatusResponseRt.encode({ + count_open_cases: openCases, + count_in_progress_cases: inProgressCases, + count_closed_cases: closedCases, + }); + } catch (error) { + throw createCaseError({ message: `Failed to get status stats: ${error}`, error, logger }); + } +} diff --git a/x-pack/plugins/cases/server/client/sub_cases/client.ts b/x-pack/plugins/cases/server/client/sub_cases/client.ts index aef780ecb3ac9..ac390710def87 100644 --- a/x-pack/plugins/cases/server/client/sub_cases/client.ts +++ b/x-pack/plugins/cases/server/client/sub_cases/client.ts @@ -17,15 +17,13 @@ import { SubCasesPatchRequest, SubCasesResponse, } from '../../../common/api'; -import { CasesClientArgs } from '..'; -import { flattenSubCaseSavedObject, transformSubCases } from '../../routes/api/utils'; -import { countAlertsForID } from '../../common'; +import { CasesClientArgs, CasesClientInternal } from '..'; +import { countAlertsForID, flattenSubCaseSavedObject, transformSubCases } from '../../common'; import { createCaseError } from '../../common/error'; import { CASE_SAVED_OBJECT } from '../../../common/constants'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; -import { constructQueryOptions } from '../../routes/api/cases/helpers'; +import { constructQueryOptions } from '../utils'; import { defaultPage, defaultPerPage } from '../../routes/api'; -import { CasesClient } from '../client'; import { update } from './update'; interface FindArgs { @@ -53,13 +51,14 @@ export interface SubCasesClient { */ export function createSubCasesClient( clientArgs: CasesClientArgs, - casesClient: CasesClient + casesClientInternal: CasesClientInternal ): SubCasesClient { return Object.freeze({ delete: (ids: string[]) => deleteSubCase(ids, clientArgs), find: (findArgs: FindArgs) => find(findArgs, clientArgs), get: (getArgs: GetArgs) => get(getArgs, clientArgs), - update: (subCases: SubCasesPatchRequest) => update(subCases, clientArgs, casesClient), + update: (subCases: SubCasesPatchRequest) => + update({ subCases, clientArgs, casesClientInternal }), }); } diff --git a/x-pack/plugins/cases/server/client/sub_cases/update.ts b/x-pack/plugins/cases/server/client/sub_cases/update.ts index 27e6e1261c0af..de7a75634d7fb 100644 --- a/x-pack/plugins/cases/server/client/sub_cases/update.ts +++ b/x-pack/plugins/cases/server/client/sub_cases/update.ts @@ -17,7 +17,6 @@ import { } from 'kibana/server'; import { nodeBuilder } from '../../../../../../src/plugins/data/common'; -import { CasesClient } from '../../client'; import { CaseService } from '../../services'; import { CaseStatuses, @@ -36,16 +35,17 @@ import { CommentAttributes, } from '../../../common/api'; import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../common/constants'; +import { getCaseToUpdate } from '../utils'; +import { buildSubCaseUserActions } from '../../services/user_actions/helpers'; import { - flattenSubCaseSavedObject, + createAlertUpdateRequest, isCommentRequestTypeAlertOrGenAlert, -} from '../../routes/api/utils'; -import { getCaseToUpdate } from '../../routes/api/cases/helpers'; -import { buildSubCaseUserActions } from '../../services/user_actions/helpers'; -import { createAlertUpdateRequest } from '../../common'; + flattenSubCaseSavedObject, +} from '../../common'; import { createCaseError } from '../../common/error'; import { UpdateAlertRequest } from '../../client/alerts/client'; import { CasesClientArgs } from '../types'; +import { CasesClientInternal } from '../client_internal'; function checkNonExistingOrConflict( toUpdate: SubCasePatchRequest[], @@ -207,13 +207,13 @@ async function getAlertComments({ async function updateAlerts({ caseService, soClient, - casesClient, + casesClientInternal, logger, subCasesToSync, }: { caseService: CaseService; soClient: SavedObjectsClientContract; - casesClient: CasesClient; + casesClientInternal: CasesClientInternal; logger: Logger; subCasesToSync: SubCasePatchRequest[]; }) { @@ -241,7 +241,7 @@ async function updateAlerts({ [] ); - await casesClient.casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate }); + await casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate }); } catch (error) { throw createCaseError({ message: `Failed to update alert status while updating sub cases: ${JSON.stringify( @@ -256,11 +256,15 @@ async function updateAlerts({ /** * Handles updating the fields in a sub case. */ -export async function update( - subCases: SubCasesPatchRequest, - clientArgs: CasesClientArgs, - casesClient: CasesClient -): Promise { +export async function update({ + subCases, + clientArgs, + casesClientInternal, +}: { + subCases: SubCasesPatchRequest; + clientArgs: CasesClientArgs; + casesClientInternal: CasesClientInternal; +}): Promise { const query = pipe( excess(SubCasesPatchRequestRt).decode(subCases), fold(throwErrors(Boom.badRequest), identity) @@ -349,7 +353,7 @@ export async function update( await updateAlerts({ caseService, soClient, - casesClient, + casesClientInternal, subCasesToSync: subCasesToSyncAlertsFor, logger: clientArgs.logger, }); diff --git a/x-pack/plugins/cases/server/client/types.ts b/x-pack/plugins/cases/server/client/types.ts index 7d50fdbb53382..5147cea0b59f0 100644 --- a/x-pack/plugins/cases/server/client/types.ts +++ b/x-pack/plugins/cases/server/client/types.ts @@ -18,6 +18,7 @@ import { ConnectorMappingsService, AttachmentService, } from '../services'; +import { ActionsClient } from '../../../actions/server'; export interface CasesClientArgs { readonly scopedClusterClient: ElasticsearchClient; @@ -32,4 +33,5 @@ export interface CasesClientArgs { readonly logger: Logger; readonly authorization: PublicMethodsOf; readonly auditLogger?: AuditLogger; + readonly actionsClient: PublicMethodsOf; } diff --git a/x-pack/plugins/cases/server/client/user_actions/client.ts b/x-pack/plugins/cases/server/client/user_actions/client.ts index 50d9270440e43..8098714f8f955 100644 --- a/x-pack/plugins/cases/server/client/user_actions/client.ts +++ b/x-pack/plugins/cases/server/client/user_actions/client.ts @@ -19,7 +19,7 @@ export interface UserActionsSubClient { } export const createUserActionsSubClient = (args: CasesClientArgs): UserActionsSubClient => { - const { savedObjectsClient, userActionService } = args; + const { savedObjectsClient, userActionService, logger } = args; const attachmentSubClient: UserActionsSubClient = { getAll: (params: UserActionGet) => @@ -27,6 +27,7 @@ export const createUserActionsSubClient = (args: CasesClientArgs): UserActionsSu ...params, savedObjectsClient, userActionService, + logger, }), }; diff --git a/x-pack/plugins/cases/server/client/user_actions/get.ts b/x-pack/plugins/cases/server/client/user_actions/get.ts index cebd3da1b6f7e..4a8d1101d19cf 100644 --- a/x-pack/plugins/cases/server/client/user_actions/get.ts +++ b/x-pack/plugins/cases/server/client/user_actions/get.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SavedObjectsClientContract } from 'kibana/server'; +import { SavedObjectsClientContract, Logger } from 'kibana/server'; import { SUB_CASE_SAVED_OBJECT, CASE_SAVED_OBJECT, @@ -13,12 +13,15 @@ import { } from '../../../common/constants'; import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api'; import { CaseUserActionService } from '../../services'; +import { createCaseError } from '../../common/error'; +import { checkEnabledCaseConnectorOrThrow } from '../../common'; interface GetParams { savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionService; caseId: string; subCaseId?: string; + logger: Logger; } export const get = async ({ @@ -26,28 +29,39 @@ export const get = async ({ userActionService, caseId, subCaseId, + logger, }: GetParams): Promise => { - const userActions = await userActionService.getAll({ - soClient: savedObjectsClient, - caseId, - subCaseId, - }); + try { + checkEnabledCaseConnectorOrThrow(subCaseId); - return CaseUserActionsResponseRt.encode( - userActions.saved_objects.reduce((acc, ua) => { - if (subCaseId == null && ua.references.some((uar) => uar.type === SUB_CASE_SAVED_OBJECT)) { - return acc; - } - return [ - ...acc, - { - ...ua.attributes, - action_id: ua.id, - case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', - comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, - sub_case_id: ua.references.find((r) => r.type === SUB_CASE_SAVED_OBJECT)?.id ?? '', - }, - ]; - }, []) - ); + const userActions = await userActionService.getAll({ + soClient: savedObjectsClient, + caseId, + subCaseId, + }); + + return CaseUserActionsResponseRt.encode( + userActions.saved_objects.reduce((acc, ua) => { + if (subCaseId == null && ua.references.some((uar) => uar.type === SUB_CASE_SAVED_OBJECT)) { + return acc; + } + return [ + ...acc, + { + ...ua.attributes, + action_id: ua.id, + case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', + comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, + sub_case_id: ua.references.find((r) => r.type === SUB_CASE_SAVED_OBJECT)?.id ?? '', + }, + ]; + }, []) + ); + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve user actions case id: ${caseId} sub case id: ${subCaseId}: ${error}`, + error, + logger, + }); + } }; diff --git a/x-pack/plugins/cases/server/client/utils.test.ts b/x-pack/plugins/cases/server/client/utils.test.ts new file mode 100644 index 0000000000000..c8ed1f4f0efa6 --- /dev/null +++ b/x-pack/plugins/cases/server/client/utils.test.ts @@ -0,0 +1,329 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsFindResponse } from 'kibana/server'; +import { + CaseConnector, + CaseType, + ConnectorTypes, + ESCaseConnector, + ESCasesConfigureAttributes, +} from '../../common/api'; +import { mockCaseConfigure } from '../routes/api/__fixtures__'; +import { newCase } from '../routes/api/__mocks__/request_responses'; +import { + transformCaseConnectorToEsConnector, + transformESConnectorToCaseConnector, + transformNewCase, +} from '../common'; +import { getConnectorFromConfiguration, sortToSnake } from './utils'; + +describe('utils', () => { + const caseConnector: CaseConnector = { + id: '123', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }; + + const esCaseConnector: ESCaseConnector = { + id: '123', + name: 'Jira', + type: ConnectorTypes.jira, + fields: [ + { key: 'issueType', value: 'Task' }, + { key: 'priority', value: 'High' }, + { key: 'parent', value: null }, + ], + }; + + const caseConfigure: SavedObjectsFindResponse = { + saved_objects: [{ ...mockCaseConfigure[0], score: 0 }], + total: 1, + per_page: 20, + page: 1, + }; + + describe('transformCaseConnectorToEsConnector', () => { + it('transform correctly', () => { + expect(transformCaseConnectorToEsConnector(caseConnector)).toEqual(esCaseConnector); + }); + + it('transform correctly with null attributes', () => { + // @ts-ignore this is case the connector does not exist for old cases object or configurations + expect(transformCaseConnectorToEsConnector(null)).toEqual({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: [], + }); + }); + }); + + describe('transformESConnectorToCaseConnector', () => { + it('transform correctly', () => { + expect(transformESConnectorToCaseConnector(esCaseConnector)).toEqual(caseConnector); + }); + + it('transform correctly with null attributes', () => { + // @ts-ignore this is case the connector does not exist for old cases object or configurations + expect(transformESConnectorToCaseConnector(null)).toEqual({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }); + }); + }); + + describe('getConnectorFromConfiguration', () => { + it('transform correctly', () => { + expect(getConnectorFromConfiguration(caseConfigure)).toEqual({ + id: '789', + name: 'My connector 3', + type: ConnectorTypes.jira, + fields: null, + }); + }); + + it('transform correctly with no connector', () => { + const caseConfigureNoConnector: SavedObjectsFindResponse = { + ...caseConfigure, + saved_objects: [ + { + ...mockCaseConfigure[0], + // @ts-ignore this is case the connector does not exist for old cases object or configurations + attributes: { ...mockCaseConfigure[0].attributes, connector: null }, + score: 0, + }, + ], + }; + + expect(getConnectorFromConfiguration(caseConfigureNoConnector)).toEqual({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }); + }); + }); + + describe('sortToSnake', () => { + it('it transforms status correctly', () => { + expect(sortToSnake('status')).toBe('status'); + }); + + it('it transforms createdAt correctly', () => { + expect(sortToSnake('createdAt')).toBe('created_at'); + }); + + it('it transforms created_at correctly', () => { + expect(sortToSnake('created_at')).toBe('created_at'); + }); + + it('it transforms closedAt correctly', () => { + expect(sortToSnake('closedAt')).toBe('closed_at'); + }); + + it('it transforms closed_at correctly', () => { + expect(sortToSnake('closed_at')).toBe('closed_at'); + }); + + it('it transforms default correctly', () => { + expect(sortToSnake('not-exist')).toBe('created_at'); + }); + }); + + describe('transformNewCase', () => { + const connector: ESCaseConnector = { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: [ + { key: 'issueType', value: 'Task' }, + { key: 'priority', value: 'High' }, + { key: 'parent', value: null }, + ], + }; + it('transform correctly', () => { + const myCase = { + newCase: { ...newCase, type: CaseType.individual }, + connector, + createdDate: '2020-04-09T09:43:51.778Z', + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }; + + const res = transformNewCase(myCase); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "description": "A description", + "external_service": null, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); + }); + + it('transform correctly without optional fields', () => { + const myCase = { + newCase: { ...newCase, type: CaseType.individual }, + connector, + createdDate: '2020-04-09T09:43:51.778Z', + }; + + const res = transformNewCase(myCase); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "description": "A description", + "external_service": null, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); + }); + + it('transform correctly with optional fields as null', () => { + const myCase = { + newCase: { ...newCase, type: CaseType.individual }, + connector, + createdDate: '2020-04-09T09:43:51.778Z', + email: null, + full_name: null, + username: null, + }; + + const res = transformNewCase(myCase); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "A description", + "external_service": null, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/helpers.ts b/x-pack/plugins/cases/server/client/utils.ts similarity index 75% rename from x-pack/plugins/cases/server/routes/api/cases/helpers.ts rename to x-pack/plugins/cases/server/client/utils.ts index f6570bb5c88cd..c56e1178e96c8 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -5,25 +5,95 @@ * 2.0. */ +import { badRequest } from '@hapi/boom'; import { get, isPlainObject } from 'lodash'; import deepEqual from 'fast-deep-equal'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; import { SavedObjectsFindResponse } from 'kibana/server'; -import { nodeBuilder, KueryNode } from '../../../../../../../src/plugins/data/common'; +import { nodeBuilder, KueryNode } from '../../../../../src/plugins/data/common'; import { CaseConnector, - ESCaseConnector, ESCasesConfigureAttributes, - ConnectorTypeFields, ConnectorTypes, CaseStatuses, CaseType, - ESConnectorFields, -} from '../../../../common/api'; -import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../common/constants'; -import { sortToSnake } from '../utils'; -import { combineFilterWithAuthorizationFilter } from '../../../authorization/utils'; -import { SavedObjectFindOptionsKueryNode } from '../../../common'; + CommentRequest, + throwErrors, + excess, + ContextTypeUserRt, + AlertCommentRequestRt, +} from '../../common/api'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../common/constants'; +import { combineFilterWithAuthorizationFilter } from '../authorization/utils'; +import { + getIDsAndIndicesAsArrays, + isCommentRequestTypeAlertOrGenAlert, + isCommentRequestTypeUser, + SavedObjectFindOptionsKueryNode, +} from '../common'; + +export const decodeCommentRequest = (comment: CommentRequest) => { + if (isCommentRequestTypeUser(comment)) { + pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isCommentRequestTypeAlertOrGenAlert(comment)) { + pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); + const { ids, indices } = getIDsAndIndicesAsArrays(comment); + + /** + * The alertId and index field must either be both of type string or they must both be string[] and be the same length. + * Having a one-to-one relationship between the id and index of an alert avoids accidentally updating or + * retrieving the wrong alert. Elasticsearch only guarantees that the _id (the field we use for alertId) to be + * unique within a single index. So if we attempt to update or get a specific alert across multiple indices we could + * update or receive the wrong one. + * + * Consider the situation where we have a alert1 with _id = '100' in index 'my-index-awesome' and also in index + * 'my-index-hi'. + * If we attempt to update the status of alert1 using an index pattern like `my-index-*` or even providing multiple + * indices, there's a chance we'll accidentally update too many alerts. + * + * This check doesn't enforce that the API request has the correct alert ID to index relationship it just guards + * against accidentally making a request like: + * { + * alertId: [1,2,3], + * index: awesome, + * } + * + * Instead this requires the requestor to provide: + * { + * alertId: [1,2,3], + * index: [awesome, awesome, awesome] + * } + * + * Ideally we'd change the format of the comment request to be an array of objects like: + * { + * alerts: [{id: 1, index: awesome}, {id: 2, index: awesome}] + * } + * + * But we'd need to also implement a migration because the saved object document currently stores the id and index + * in separate fields. + */ + if (ids.length !== indices.length) { + throw badRequest( + `Received an alert comment with ids and indices arrays of different lengths ids: ${JSON.stringify( + ids + )} indices: ${JSON.stringify(indices)}` + ); + } + } +}; + +/** + * Return the alert IDs from the comment if it is an alert style comment. Otherwise return an empty array. + */ +export const getAlertIds = (comment: CommentRequest): string[] => { + if (isCommentRequestTypeAlertOrGenAlert(comment)) { + return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + } + return []; +}; export const addStatusFilter = ({ status, @@ -353,43 +423,23 @@ export const getConnectorFromConfiguration = ( return caseConnector; }; -export const transformCaseConnectorToEsConnector = (connector: CaseConnector): ESCaseConnector => ({ - id: connector?.id ?? 'none', - name: connector?.name ?? 'none', - type: connector?.type ?? '.none', - fields: - connector?.fields != null - ? Object.entries(connector.fields).reduce( - (acc, [key, value]) => [ - ...acc, - { - key, - value, - }, - ], - [] - ) - : [], -}); +enum SortFieldCase { + closedAt = 'closed_at', + createdAt = 'created_at', + status = 'status', +} -export const transformESConnectorToCaseConnector = (connector?: ESCaseConnector): CaseConnector => { - const connectorTypeField = { - type: connector?.type ?? '.none', - fields: - connector && connector.fields != null && connector.fields.length > 0 - ? connector.fields.reduce( - (fields, { key, value }) => ({ - ...fields, - [key]: value, - }), - {} - ) - : null, - } as ConnectorTypeFields; - - return { - id: connector?.id ?? 'none', - name: connector?.name ?? 'none', - ...connectorTypeField, - }; +export const sortToSnake = (sortField: string | undefined): SortFieldCase => { + switch (sortField) { + case 'status': + return SortFieldCase.status; + case 'createdAt': + case 'created_at': + return SortFieldCase.createdAt; + case 'closedAt': + case 'closed_at': + return SortFieldCase.closedAt; + default: + return SortFieldCase.createdAt; + } }; diff --git a/x-pack/plugins/cases/server/common/error.ts b/x-pack/plugins/cases/server/common/error.ts index 95b05fd612e60..1b53eb9fdb218 100644 --- a/x-pack/plugins/cases/server/common/error.ts +++ b/x-pack/plugins/cases/server/common/error.ts @@ -28,7 +28,7 @@ class CaseError extends Error { * and data from that. */ public boomify(): Boom { - const message = this.message ?? this.wrappedError?.message; + const message = this.wrappedError?.message ?? this.message; let statusCode = 500; let data: unknown | undefined; diff --git a/x-pack/plugins/cases/server/common/index.ts b/x-pack/plugins/cases/server/common/index.ts index b07ed5d4ae2d6..324c7e7ffd1a8 100644 --- a/x-pack/plugins/cases/server/common/index.ts +++ b/x-pack/plugins/cases/server/common/index.ts @@ -8,3 +8,4 @@ export * from './models'; export * from './utils'; export * from './types'; +export * from './error'; diff --git a/x-pack/plugins/cases/server/common/models/commentable_case.ts b/x-pack/plugins/cases/server/common/models/commentable_case.ts index fb34c5fecea39..d2276c0027ece 100644 --- a/x-pack/plugins/cases/server/common/models/commentable_case.ts +++ b/x-pack/plugins/cases/server/common/models/commentable_case.ts @@ -28,12 +28,12 @@ import { SubCaseAttributes, User, } from '../../../common/api'; -import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; import { + transformESConnectorToCaseConnector, flattenCommentSavedObjects, flattenSubCaseSavedObject, transformNewComment, -} from '../../routes/api/utils'; +} from '..'; import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../common/constants'; import { AttachmentService, CaseService } from '../../services'; import { createCaseError } from '../error'; diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 46e73c8b5d79c..e7dcbf0111f55 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -6,9 +6,29 @@ */ import { SavedObjectsFindResponse } from 'kibana/server'; -import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common/api'; -import { transformNewComment } from '../routes/api/utils'; -import { countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; +import { + AssociationType, + CaseResponse, + CommentAttributes, + CommentRequest, + CommentType, +} from '../../common/api'; +import { + mockCaseComments, + mockCases, + mockCaseNoConnectorId, +} from '../routes/api/__fixtures__/mock_saved_objects'; +import { + flattenCaseSavedObject, + transformNewComment, + countAlerts, + countAlertsForID, + groupTotalAlertsByID, + transformCases, + transformComments, + flattenCommentSavedObjects, + flattenCommentSavedObject, +} from './utils'; interface CommentReference { ids: string[]; @@ -47,6 +67,609 @@ function createCommentFindResponse( } describe('common utils', () => { + describe('transformCases', () => { + it('transforms correctly', () => { + const casesMap = new Map( + mockCases.map((obj) => { + return [obj.id, flattenCaseSavedObject({ savedObject: obj, totalComment: 2 })]; + }) + ); + const res = transformCases({ + casesMap, + countOpenCases: 2, + countInProgressCases: 2, + countClosedCases: 2, + page: 1, + perPage: 10, + total: casesMap.size, + }); + expect(res).toMatchInlineSnapshot(` + Object { + "cases": Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T22:32:00.900Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie destroying data!", + "external_service": null, + "id": "mock-id-2", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "Data Destruction", + ], + "title": "Damaging Data Destruction Detected", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:00.900Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzQsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + Object { + "closed_at": "2019-11-25T22:32:17.947Z", + "closed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + ], + "count_closed_cases": 2, + "count_in_progress_cases": 2, + "count_open_cases": 2, + "page": 1, + "per_page": 10, + "total": 4, + } + `); + }); + }); + + describe('flattenCaseSavedObject', () => { + it('flattens correctly', () => { + const myCase = { ...mockCases[2] }; + const res = flattenCaseSavedObject({ + savedObject: myCase, + totalComment: 2, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + } + `); + }); + + it('flattens correctly without version', () => { + const myCase = { ...mockCases[2] }; + myCase.version = undefined; + const res = flattenCaseSavedObject({ + savedObject: myCase, + totalComment: 2, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "0", + } + `); + }); + + it('flattens correctly with comments', () => { + const myCase = { ...mockCases[2] }; + const comments = [{ ...mockCaseComments[0] }]; + const res = flattenCaseSavedObject({ + savedObject: myCase, + comments, + totalComment: 2, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [ + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:55:00.177Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "id": "mock-comment-1", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": "2019-11-25T21:55:00.177Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzEsMV0=", + }, + ], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + } + `); + }); + + it('inserts missing connector', () => { + const extraCaseData = { + totalComment: 2, + }; + + const res = flattenCaseSavedObject({ + // @ts-ignore this is to update old case saved objects to include connector + savedObject: mockCaseNoConnectorId, + ...extraCaseData, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + } + `); + }); + }); + + describe('transformComments', () => { + it('transforms correctly', () => { + const comments = { + saved_objects: mockCaseComments.map((obj) => ({ ...obj, score: 1 })), + total: mockCaseComments.length, + per_page: 10, + page: 1, + }; + + const res = transformComments(comments); + expect(res).toEqual({ + page: 1, + per_page: 10, + total: mockCaseComments.length, + comments: flattenCommentSavedObjects(comments.saved_objects), + }); + }); + }); + + describe('flattenCommentSavedObjects', () => { + it('flattens correctly', () => { + const comments = [{ ...mockCaseComments[0] }, { ...mockCaseComments[1] }]; + const res = flattenCommentSavedObjects(comments); + expect(res).toEqual([ + flattenCommentSavedObject(comments[0]), + flattenCommentSavedObject(comments[1]), + ]); + }); + }); + + describe('flattenCommentSavedObject', () => { + it('flattens correctly', () => { + const comment = { ...mockCaseComments[0] }; + const res = flattenCommentSavedObject(comment); + expect(res).toEqual({ + id: comment.id, + version: comment.version, + ...comment.attributes, + }); + }); + + it('flattens correctly without version', () => { + const comment = { ...mockCaseComments[0] }; + comment.version = undefined; + const res = flattenCommentSavedObject(comment); + expect(res).toEqual({ + id: comment.id, + version: '0', + ...comment.attributes, + }); + }); + }); + + describe('transformNewComment', () => { + it('transforms correctly', () => { + const comment = { + comment: 'A comment', + type: CommentType.user as const, + createdDate: '2020-04-09T09:43:51.778Z', + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + associationType: AssociationType.case, + }; + + const res = transformNewComment(comment); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); + }); + + it('transform correctly without optional fields', () => { + const comment = { + comment: 'A comment', + type: CommentType.user as const, + createdDate: '2020-04-09T09:43:51.778Z', + associationType: AssociationType.case, + }; + + const res = transformNewComment(comment); + + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); + }); + + it('transform correctly with optional fields as null', () => { + const comment = { + comment: 'A comment', + type: CommentType.user as const, + createdDate: '2020-04-09T09:43:51.778Z', + email: null, + full_name: null, + username: null, + associationType: AssociationType.case, + }; + + const res = transformNewComment(comment); + + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); + }); + }); + describe('countAlerts', () => { it('returns 0 when no alerts are found', () => { expect( diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index af638c39d6609..def25b8c7acec 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -4,19 +4,38 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import Boom from '@hapi/boom'; -import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; +import { SavedObjectsFindResult, SavedObjectsFindResponse, SavedObject } from 'kibana/server'; +import { isEmpty } from 'lodash'; +import { AlertInfo } from '.'; import { AuditEvent, EventCategory, EventOutcome } from '../../../security/server'; import { + AssociationType, + CaseConnector, + CaseResponse, + CasesClientPostRequest, + CasesFindResponse, CaseStatuses, CommentAttributes, CommentRequest, + CommentRequestAlertType, + CommentRequestUserType, + CommentResponse, + CommentsResponse, CommentType, + ConnectorTypeFields, + ESCaseAttributes, + ESCaseConnector, + ESConnectorFields, + SubCaseAttributes, + SubCaseResponse, + SubCasesFindResponse, User, } from '../../common/api'; +import { ENABLE_CASE_CONNECTOR } from '../../common/constants'; import { OperationDetails } from '../authorization'; import { UpdateAlertRequest } from '../client/alerts/client'; -import { getAlertInfoFromComments } from '../routes/api/utils'; /** * Default sort field for querying saved objects. @@ -28,6 +47,303 @@ export const defaultSortField = 'created_at'; */ export const nullUser: User = { username: null, full_name: null, email: null }; +export const transformNewCase = ({ + connector, + createdDate, + email, + // eslint-disable-next-line @typescript-eslint/naming-convention + full_name, + newCase, + username, +}: { + connector: ESCaseConnector; + createdDate: string; + email?: string | null; + full_name?: string | null; + newCase: CasesClientPostRequest; + username?: string | null; +}): ESCaseAttributes => ({ + ...newCase, + closed_at: null, + closed_by: null, + connector, + created_at: createdDate, + created_by: { email, full_name, username }, + external_service: null, + status: CaseStatuses.open, + updated_at: null, + updated_by: null, +}); + +export const transformCases = ({ + casesMap, + countOpenCases, + countInProgressCases, + countClosedCases, + page, + perPage, + total, +}: { + casesMap: Map; + countOpenCases: number; + countInProgressCases: number; + countClosedCases: number; + page: number; + perPage: number; + total: number; +}): CasesFindResponse => ({ + page, + per_page: perPage, + total, + cases: Array.from(casesMap.values()), + count_open_cases: countOpenCases, + count_in_progress_cases: countInProgressCases, + count_closed_cases: countClosedCases, +}); + +export const transformSubCases = ({ + subCasesMap, + open, + inProgress, + closed, + page, + perPage, + total, +}: { + subCasesMap: Map; + open: number; + inProgress: number; + closed: number; + page: number; + perPage: number; + total: number; +}): SubCasesFindResponse => ({ + page, + per_page: perPage, + total, + // Squish all the entries in the map together as one array + subCases: Array.from(subCasesMap.values()).flat(), + count_open_cases: open, + count_in_progress_cases: inProgress, + count_closed_cases: closed, +}); + +export const flattenCaseSavedObject = ({ + savedObject, + comments = [], + totalComment = comments.length, + totalAlerts = 0, + subCases, + subCaseIds, +}: { + savedObject: SavedObject; + comments?: Array>; + totalComment?: number; + totalAlerts?: number; + subCases?: SubCaseResponse[]; + subCaseIds?: string[]; +}): CaseResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', + comments: flattenCommentSavedObjects(comments), + totalComment, + totalAlerts, + ...savedObject.attributes, + connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), + subCases, + subCaseIds: !isEmpty(subCaseIds) ? subCaseIds : undefined, +}); + +export const flattenSubCaseSavedObject = ({ + savedObject, + comments = [], + totalComment = comments.length, + totalAlerts = 0, +}: { + savedObject: SavedObject; + comments?: Array>; + totalComment?: number; + totalAlerts?: number; +}): SubCaseResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', + comments: flattenCommentSavedObjects(comments), + totalComment, + totalAlerts, + ...savedObject.attributes, +}); + +export const transformComments = ( + comments: SavedObjectsFindResponse +): CommentsResponse => ({ + page: comments.page, + per_page: comments.per_page, + total: comments.total, + comments: flattenCommentSavedObjects(comments.saved_objects), +}); + +export const flattenCommentSavedObjects = ( + savedObjects: Array> +): CommentResponse[] => + savedObjects.reduce((acc: CommentResponse[], savedObject: SavedObject) => { + return [...acc, flattenCommentSavedObject(savedObject)]; + }, []); + +export const flattenCommentSavedObject = ( + savedObject: SavedObject +): CommentResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', + ...savedObject.attributes, +}); + +export const transformCaseConnectorToEsConnector = (connector: CaseConnector): ESCaseConnector => ({ + id: connector?.id ?? 'none', + name: connector?.name ?? 'none', + type: connector?.type ?? '.none', + fields: + connector?.fields != null + ? Object.entries(connector.fields).reduce( + (acc, [key, value]) => [ + ...acc, + { + key, + value, + }, + ], + [] + ) + : [], +}); + +export const transformESConnectorToCaseConnector = (connector?: ESCaseConnector): CaseConnector => { + const connectorTypeField = { + type: connector?.type ?? '.none', + fields: + connector && connector.fields != null && connector.fields.length > 0 + ? connector.fields.reduce( + (fields, { key, value }) => ({ + ...fields, + [key]: value, + }), + {} + ) + : null, + } as ConnectorTypeFields; + + return { + id: connector?.id ?? 'none', + name: connector?.name ?? 'none', + ...connectorTypeField, + }; +}; + +export const getIDsAndIndicesAsArrays = ( + comment: CommentRequestAlertType +): { ids: string[]; indices: string[] } => { + return { + ids: Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId], + indices: Array.isArray(comment.index) ? comment.index : [comment.index], + }; +}; + +/** + * This functions extracts the ids and indices from an alert comment. It enforces that the alertId and index are either + * both strings or string arrays that are the same length. If they are arrays they represent a 1-to-1 mapping of + * id existing in an index at each position in the array. This is not ideal. Ideally an alert comment request would + * accept an array of objects like this: Array<{id: string; index: string; ruleName: string ruleID: string}> instead. + * + * To reformat the alert comment request requires a migration and a breaking API change. + */ +const getAndValidateAlertInfoFromComment = (comment: CommentRequest): AlertInfo[] => { + if (!isCommentRequestTypeAlertOrGenAlert(comment)) { + return []; + } + + const { ids, indices } = getIDsAndIndicesAsArrays(comment); + + if (ids.length !== indices.length) { + return []; + } + + return ids.map((id, index) => ({ id, index: indices[index] })); +}; + +/** + * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alerts. + */ +export const getAlertInfoFromComments = (comments: CommentRequest[] | undefined): AlertInfo[] => { + if (comments === undefined) { + return []; + } + + return comments.reduce((acc: AlertInfo[], comment) => { + const alertInfo = getAndValidateAlertInfoFromComment(comment); + acc.push(...alertInfo); + return acc; + }, []); +}; + +type NewCommentArgs = CommentRequest & { + associationType: AssociationType; + createdDate: string; + email?: string | null; + full_name?: string | null; + username?: string | null; +}; + +export const transformNewComment = ({ + associationType, + createdDate, + email, + // eslint-disable-next-line @typescript-eslint/naming-convention + full_name, + username, + ...comment +}: NewCommentArgs): CommentAttributes => { + return { + associationType, + ...comment, + created_at: createdDate, + created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; +}; + +/** + * A type narrowing function for user comments. Exporting so integration tests can use it. + */ +export const isCommentRequestTypeUser = ( + context: CommentRequest +): context is CommentRequestUserType => { + return context.type === CommentType.user; +}; + +/** + * A type narrowing function for alert comments. Exporting so integration tests can use it. + */ +export const isCommentRequestTypeAlertOrGenAlert = ( + context: CommentRequest +): context is CommentRequestAlertType => { + return context.type === CommentType.alert || context.type === CommentType.generatedAlert; +}; + +/** + * This is used to test if the posted comment is an generated alert. A generated alert will have one or many alerts. + * An alert is essentially an object with a _id field. This differs from a regular attached alert because the _id is + * passed directly in the request, it won't be in an object. Internally case will strip off the outer object and store + * both a generated and user attached alert in the same structure but this function is useful to determine which + * structure the new alert in the request has. + */ +export const isCommentRequestTypeGenAlert = ( + context: CommentRequest +): context is CommentRequestAlertType => { + return context.type === CommentType.generatedAlert; +}; + /** * Adds the ids and indices to a map of statuses */ @@ -145,3 +461,14 @@ export function createAuditMsg({ }), }; } + +/** + * If subCaseID is defined and the case connector feature is disabled this throws an error. + */ +export function checkEnabledCaseConnectorOrThrow(subCaseID: string | undefined) { + if (!ENABLE_CASE_CONNECTOR && subCaseID !== undefined) { + throw Boom.badRequest( + 'The sub case parameters are not supported when the case connector feature is disabled' + ); + } +} diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 8a504ce73dee8..4493e04f307c4 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -9,7 +9,10 @@ import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } fro import { CoreSetup, CoreStart } from 'src/core/server'; import { SecurityPluginSetup, SecurityPluginStart } from '../../security/server'; -import { PluginSetupContract as ActionsPluginSetup } from '../../actions/server'; +import { + PluginSetupContract as ActionsPluginSetup, + PluginStartContract as ActionsPluginStart, +} from '../../actions/server'; import { APP_ID, ENABLE_CASE_CONNECTOR } from '../common/constants'; import { ConfigType } from './config'; @@ -50,6 +53,7 @@ export interface PluginsStart { security?: SecurityPluginStart; features: FeaturesPluginStart; spaces?: SpacesPluginStart; + actions: ActionsPluginStart; } export class CasePlugin { @@ -143,6 +147,7 @@ export class CasePlugin { return plugins.spaces?.spacesService.getActiveSpace(request); }, featuresPluginStart: plugins.features, + actionsPluginStart: plugins.actions, }); const getCasesClientWithRequestAndContext = async ( diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts index 4439b215599a9..08c4491f7b151 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts @@ -5,25 +5,12 @@ * 2.0. */ -import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { - CASE_COMMENTS_URL, - ENABLE_CASE_CONNECTOR, - SAVED_OBJECT_TYPES, -} from '../../../../../common/constants'; -import { AssociationType } from '../../../../../common/api'; +import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -export function initDeleteAllCommentsApi({ - attachmentService, - caseService, - router, - userActionService, - logger, -}: RouteDeps) { +export function initDeleteAllCommentsApi({ router, logger }: RouteDeps) { router.delete( { path: CASE_COMMENTS_URL, @@ -40,49 +27,11 @@ export function initDeleteAllCommentsApi({ }, async (context, request, response) => { try { - if (!ENABLE_CASE_CONNECTOR && request.query?.subCaseId !== undefined) { - throw Boom.badRequest( - 'The `subCaseId` is not supported when the case connector feature is disabled' - ); - } + const client = await context.cases.getCasesClient(); - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const deleteDate = new Date().toISOString(); - - const subCaseId = request.query?.subCaseId; - const id = subCaseId ?? request.params.case_id; - const comments = await caseService.getCommentsByAssociation({ - soClient, - id, - associationType: subCaseId ? AssociationType.subCase : AssociationType.case, - }); - - await Promise.all( - comments.saved_objects.map((comment) => - attachmentService.delete({ - soClient, - attachmentId: comment.id, - }) - ) - ); - - await userActionService.bulkCreate({ - soClient, - actions: comments.saved_objects.map((comment) => - buildCommentUserActionItem({ - action: 'delete', - actionAt: deleteDate, - actionBy: { username, full_name, email }, - caseId: request.params.case_id, - subCaseId, - commentId: comment.id, - fields: ['comment'], - }) - ), + await client.attachments.deleteAll({ + caseID: request.params.case_id, + subCaseID: request.query?.subCaseId, }); return response.noContent(); diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts index da4064f64be77..284013ff36c09 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts @@ -5,27 +5,13 @@ * 2.0. */ -import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { - CASE_COMMENT_DETAILS_URL, - SAVED_OBJECT_TYPES, - CASE_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, - ENABLE_CASE_CONNECTOR, -} from '../../../../../common/constants'; +import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; -export function initDeleteCommentApi({ - attachmentService, - caseService, - router, - userActionService, - logger, -}: RouteDeps) { +export function initDeleteCommentApi({ router, logger }: RouteDeps) { router.delete( { path: CASE_COMMENT_DETAILS_URL, @@ -43,54 +29,11 @@ export function initDeleteCommentApi({ }, async (context, request, response) => { try { - if (!ENABLE_CASE_CONNECTOR && request.query?.subCaseId !== undefined) { - throw Boom.badRequest( - 'The `subCaseId` is not supported when the case connector feature is disabled' - ); - } - - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = caseService.getUser({ request }); - const deleteDate = new Date().toISOString(); - - const myComment = await attachmentService.get({ - soClient, - attachmentId: request.params.comment_id, - }); - - if (myComment == null) { - throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); - } - - const type = request.query?.subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; - const id = request.query?.subCaseId ?? request.params.case_id; - - const caseRef = myComment.references.find((c) => c.type === type); - if (caseRef == null || (caseRef != null && caseRef.id !== id)) { - throw Boom.notFound(`This comment ${request.params.comment_id} does not exist in ${id}.`); - } - - await attachmentService.delete({ - soClient, - attachmentId: request.params.comment_id, - }); - - await userActionService.bulkCreate({ - soClient, - actions: [ - buildCommentUserActionItem({ - action: 'delete', - actionAt: deleteDate, - actionBy: { username, full_name, email }, - caseId: id, - subCaseId: request.query?.subCaseId, - commentId: request.params.comment_id, - fields: ['comment'], - }), - ], + const client = await context.cases.getCasesClient(); + await client.attachments.delete({ + attachmentID: request.params.comment_id, + subCaseID: request.query?.subCaseId, + caseID: request.params.case_id, }); return response.noContent(); diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts index 988d0324ec02a..b7b8a3b44146f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts @@ -14,28 +14,17 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { esKuery } from '../../../../../../../../src/plugins/data/server'; -import { - AssociationType, - CommentsResponseRt, - SavedObjectFindOptionsRt, - throwErrors, -} from '../../../../../common/api'; +import { SavedObjectFindOptionsRt, throwErrors } from '../../../../../common/api'; import { RouteDeps } from '../../types'; -import { escapeHatch, transformComments, wrapError } from '../../utils'; -import { - CASE_COMMENTS_URL, - SAVED_OBJECT_TYPES, - ENABLE_CASE_CONNECTOR, -} from '../../../../../common/constants'; -import { defaultPage, defaultPerPage } from '../..'; +import { escapeHatch, wrapError } from '../../utils'; +import { CASE_COMMENTS_URL } from '../../../../../common/constants'; const FindQueryParamsRt = rt.partial({ ...SavedObjectFindOptionsRt.props, subCaseId: rt.string, }); -export function initFindCaseCommentsApi({ caseService, router, logger }: RouteDeps) { +export function initFindCaseCommentsApi({ router, logger }: RouteDeps) { router.get( { path: `${CASE_COMMENTS_URL}/_find`, @@ -48,54 +37,18 @@ export function initFindCaseCommentsApi({ caseService, router, logger }: RouteDe }, async (context, request, response) => { try { - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); const query = pipe( FindQueryParamsRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) ); - if (!ENABLE_CASE_CONNECTOR && query.subCaseId !== undefined) { - throw Boom.badRequest( - 'The `subCaseId` is not supported when the case connector feature is disabled' - ); - } - - const id = query.subCaseId ?? request.params.case_id; - const associationType = query.subCaseId ? AssociationType.subCase : AssociationType.case; - const { filter, ...queryWithoutFilter } = query; - const args = query - ? { - caseService, - soClient, - id, - options: { - // We need this because the default behavior of getAllCaseComments is to return all the comments - // unless the page and/or perPage is specified. Since we're spreading the query after the request can - // still override this behavior. - page: defaultPage, - perPage: defaultPerPage, - sortField: 'created_at', - filter: filter != null ? esKuery.fromKueryExpression(filter) : filter, - ...queryWithoutFilter, - }, - associationType, - } - : { - caseService, - soClient, - id, - options: { - page: defaultPage, - perPage: defaultPerPage, - sortField: 'created_at', - }, - associationType, - }; - - const theComments = await caseService.getCommentsByAssociation(args); - return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); + const client = await context.cases.getCasesClient(); + return response.ok({ + body: await client.attachments.find({ + caseID: request.params.case_id, + queryParams: query, + }), + }); } catch (error) { logger.error( `Failed to find comments in route case id: ${request.params.case_id}: ${error}` diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts index af87cbccb3bf3..7777a0b36a1f1 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts @@ -5,21 +5,13 @@ * 2.0. */ -import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { SavedObjectsFindResponse } from 'kibana/server'; -import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common/api'; import { RouteDeps } from '../../types'; -import { flattenCommentSavedObjects, wrapError } from '../../utils'; -import { - CASE_COMMENTS_URL, - SAVED_OBJECT_TYPES, - ENABLE_CASE_CONNECTOR, -} from '../../../../../common/constants'; -import { defaultSortField } from '../../../../common'; +import { wrapError } from '../../utils'; +import { CASE_COMMENTS_URL } from '../../../../../common/constants'; -export function initGetAllCommentsApi({ caseService, router, logger }: RouteDeps) { +export function initGetAllCommentsApi({ router, logger }: RouteDeps) { router.get( { path: CASE_COMMENTS_URL, @@ -37,42 +29,14 @@ export function initGetAllCommentsApi({ caseService, router, logger }: RouteDeps }, async (context, request, response) => { try { - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - let comments: SavedObjectsFindResponse; - - if ( - !ENABLE_CASE_CONNECTOR && - (request.query?.subCaseId !== undefined || - request.query?.includeSubCaseComments !== undefined) - ) { - throw Boom.badRequest( - 'The `subCaseId` and `includeSubCaseComments` are not supported when the case connector feature is disabled' - ); - } - - if (request.query?.subCaseId) { - comments = await caseService.getAllSubCaseComments({ - soClient, - id: request.query.subCaseId, - options: { - sortField: defaultSortField, - }, - }); - } else { - comments = await caseService.getAllCaseComments({ - soClient, - id: request.params.case_id, - includeSubCaseComments: request.query?.includeSubCaseComments, - options: { - sortField: defaultSortField, - }, - }); - } + const client = await context.cases.getCasesClient(); return response.ok({ - body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), + body: await client.attachments.getAll({ + caseID: request.params.case_id, + includeSubCaseComments: request.query?.includeSubCaseComments, + subCaseID: request.query?.subCaseId, + }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts index a03ed4a66e805..cf6f7d62dcf6e 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts @@ -7,12 +7,11 @@ import { schema } from '@kbn/config-schema'; -import { CommentResponseRt } from '../../../../../common/api'; import { RouteDeps } from '../../types'; -import { flattenCommentSavedObject, wrapError } from '../../utils'; -import { CASE_COMMENT_DETAILS_URL, SAVED_OBJECT_TYPES } from '../../../../../common/constants'; +import { wrapError } from '../../utils'; +import { CASE_COMMENT_DETAILS_URL } from '../../../../../common/constants'; -export function initGetCommentApi({ attachmentService, router, logger }: RouteDeps) { +export function initGetCommentApi({ router, logger }: RouteDeps) { router.get( { path: CASE_COMMENT_DETAILS_URL, @@ -25,16 +24,13 @@ export function initGetCommentApi({ attachmentService, router, logger }: RouteDe }, async (context, request, response) => { try { - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); + const client = await context.cases.getCasesClient(); - const comment = await attachmentService.get({ - soClient, - attachmentId: request.params.comment_id, - }); return response.ok({ - body: CommentResponseRt.encode(flattenCommentSavedObject(comment)), + body: await client.attachments.get({ + attachmentID: request.params.comment_id, + caseID: request.params.case_id, + }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts index b9755cae41133..28852eca3af41 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts @@ -5,86 +5,18 @@ * 2.0. */ -import { pick } from 'lodash/fp'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; -import { SavedObjectsClientContract, Logger } from 'kibana/server'; -import { CommentableCase } from '../../../../common'; -import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError, decodeCommentRequest } from '../../utils'; -import { - CASE_COMMENTS_URL, - SAVED_OBJECT_TYPES, - CASE_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, - ENABLE_CASE_CONNECTOR, -} from '../../../../../common/constants'; -import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/api'; -import { CaseService, AttachmentService } from '../../../../services'; +import { escapeHatch, wrapError } from '../../utils'; +import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CommentPatchRequestRt, throwErrors } from '../../../../../common/api'; -interface CombinedCaseParams { - attachmentService: AttachmentService; - caseService: CaseService; - soClient: SavedObjectsClientContract; - caseID: string; - logger: Logger; - subCaseId?: string; -} - -async function getCommentableCase({ - attachmentService, - caseService, - soClient, - caseID, - subCaseId, - logger, -}: CombinedCaseParams) { - if (subCaseId) { - const [caseInfo, subCase] = await Promise.all([ - caseService.getCase({ - soClient, - id: caseID, - }), - caseService.getSubCase({ - soClient, - id: subCaseId, - }), - ]); - return new CommentableCase({ - attachmentService, - caseService, - collection: caseInfo, - subCase, - soClient, - logger, - }); - } else { - const caseInfo = await caseService.getCase({ - soClient, - id: caseID, - }); - return new CommentableCase({ - attachmentService, - caseService, - collection: caseInfo, - soClient, - logger, - }); - } -} - -export function initPatchCommentApi({ - attachmentService, - caseService, - router, - userActionService, - logger, -}: RouteDeps) { +export function initPatchCommentApi({ router, logger }: RouteDeps) { router.patch( { path: CASE_COMMENTS_URL, @@ -102,101 +34,19 @@ export function initPatchCommentApi({ }, async (context, request, response) => { try { - if (!ENABLE_CASE_CONNECTOR && request.query?.subCaseId !== undefined) { - throw Boom.badRequest( - 'The `subCaseId` is not supported when the case connector feature is disabled' - ); - } - - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); const query = pipe( CommentPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const { id: queryCommentId, version: queryCommentVersion, ...queryRestAttributes } = query; - decodeCommentRequest(queryRestAttributes); - - const commentableCase = await getCommentableCase({ - attachmentService, - caseService, - soClient, - caseID: request.params.case_id, - subCaseId: request.query?.subCaseId, - logger, - }); - - const myComment = await attachmentService.get({ - soClient, - attachmentId: queryCommentId, - }); - - if (myComment == null) { - throw Boom.notFound(`This comment ${queryCommentId} does not exist anymore.`); - } - - if (myComment.attributes.type !== queryRestAttributes.type) { - throw Boom.badRequest(`You cannot change the type of the comment.`); - } - - const saveObjType = request.query?.subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; - - const caseRef = myComment.references.find((c) => c.type === saveObjType); - if (caseRef == null || (caseRef != null && caseRef.id !== commentableCase.id)) { - throw Boom.notFound( - `This comment ${queryCommentId} does not exist in ${commentableCase.id}).` - ); - } - - if (queryCommentVersion !== myComment.version) { - throw Boom.conflict( - 'This case has been updated. Please refresh before saving additional updates.' - ); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const userInfo: User = { - username, - full_name, - email, - }; - - const updatedDate = new Date().toISOString(); - const { - comment: updatedComment, - commentableCase: updatedCase, - } = await commentableCase.updateComment({ - updateRequest: query, - updatedAt: updatedDate, - user: userInfo, - }); - - await userActionService.bulkCreate({ - soClient, - actions: [ - buildCommentUserActionItem({ - action: 'update', - actionAt: updatedDate, - actionBy: { username, full_name, email }, - caseId: request.params.case_id, - subCaseId: request.query?.subCaseId, - commentId: updatedComment.id, - fields: ['comment'], - newValue: JSON.stringify(queryRestAttributes), - oldValue: JSON.stringify( - // We are interested only in ContextBasicRt attributes - // myComment.attribute contains also CommentAttributesBasicRt attributes - pick(Object.keys(queryRestAttributes), myComment.attributes) - ), - }), - ], - }); + const client = await context.cases.getCasesClient(); return response.ok({ - body: await updatedCase.encode(), + body: await client.attachments.update({ + caseID: request.params.case_id, + subCaseID: request.query?.subCaseId, + updateRequest: query, + }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts index fa97796228bd1..933a53eb8a870 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts @@ -5,14 +5,11 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { CaseConfigureResponseRt, ConnectorMappingsAttributes } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_CONFIGURE_URL, SAVED_OBJECT_TYPES } from '../../../../../common/constants'; -import { transformESConnectorToCaseConnector } from '../helpers'; +import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; -export function initGetCaseConfigure({ caseConfigureService, router, logger }: RouteDeps) { +export function initGetCaseConfigure({ router, logger }: RouteDeps) { router.get( { path: CASE_CONFIGURE_URL, @@ -20,49 +17,10 @@ export function initGetCaseConfigure({ caseConfigureService, router, logger }: R }, async (context, request, response) => { try { - let error = null; - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - - const myCaseConfigure = await caseConfigureService.find({ soClient }); - - const { connector, ...caseConfigureWithoutConnector } = myCaseConfigure.saved_objects[0] - ?.attributes ?? { connector: null }; - let mappings: ConnectorMappingsAttributes[] = []; - if (connector != null) { - if (!context.cases) { - throw Boom.badRequest('RouteHandlerContext is not registered for cases'); - } - const casesClient = await context.cases.getCasesClient(); - const actionsClient = context.actions?.getActionsClient(); - if (actionsClient == null) { - throw Boom.notFound('Action client not found'); - } - try { - mappings = await casesClient.casesClientInternal.configuration.getMappings({ - actionsClient, - connectorId: connector.id, - connectorType: connector.type, - }); - } catch (e) { - error = e.isBoom - ? e.output.payload.message - : `Error connecting to ${connector.name} instance`; - } - } + const client = await context.cases.getCasesClient(); return response.ok({ - body: - myCaseConfigure.saved_objects.length > 0 - ? CaseConfigureResponseRt.encode({ - ...caseConfigureWithoutConnector, - connector: transformESConnectorToCaseConnector(connector), - mappings, - version: myCaseConfigure.saved_objects[0].version ?? '', - error, - }) - : {}, + body: await client.configure.get(), }); } catch (error) { logger.error(`Failed to get case configure in route: ${error}`); diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts index 81ffc06355ff5..be05d1c3b8230 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts @@ -5,29 +5,14 @@ * 2.0. */ -import Boom from '@hapi/boom'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { ActionType } from '../../../../../../actions/common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { FindActionResult } from '../../../../../../actions/server/types'; -import { - CASE_CONFIGURE_CONNECTORS_URL, - SUPPORTED_CONNECTORS, -} from '../../../../../common/constants'; - -const isConnectorSupported = ( - action: FindActionResult, - actionTypes: Record -): boolean => - SUPPORTED_CONNECTORS.includes(action.actionTypeId) && - actionTypes[action.actionTypeId]?.enabledInLicense; +import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../common/constants'; /* * Be aware that this api will only return 20 connectors */ - export function initCaseConfigureGetActionConnector({ router, logger }: RouteDeps) { router.get( { @@ -36,21 +21,9 @@ export function initCaseConfigureGetActionConnector({ router, logger }: RouteDep }, async (context, request, response) => { try { - const actionsClient = context.actions?.getActionsClient(); - - if (actionsClient == null) { - throw Boom.notFound('Action client not found'); - } - - const actionTypes = (await actionsClient.listTypes()).reduce( - (types, type) => ({ ...types, [type.id]: type }), - {} - ); + const client = await context.cases.getCasesClient(); - const results = (await actionsClient.getAll()).filter((action) => - isConnectorSupported(action, actionTypes) - ); - return response.ok({ body: results }); + return response.ok({ body: await client.configure.getConnectors() }); } catch (error) { logger.error(`Failed to get connectors in route: ${error}`); return response.customError(wrapError(error)); diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts index 61f3e4719520a..d32c7151f6df5 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts @@ -10,26 +10,12 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { - CasesConfigurePatchRt, - CaseConfigureResponseRt, - throwErrors, - ConnectorMappingsAttributes, -} from '../../../../../common/api'; +import { CasesConfigurePatchRt, throwErrors } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { wrapError, escapeHatch } from '../../utils'; -import { CASE_CONFIGURE_URL, SAVED_OBJECT_TYPES } from '../../../../../common/constants'; -import { - transformCaseConnectorToEsConnector, - transformESConnectorToCaseConnector, -} from '../helpers'; +import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; -export function initPatchCaseConfigure({ - caseConfigureService, - caseService, - router, - logger, -}: RouteDeps) { +export function initPatchCaseConfigure({ router, logger }: RouteDeps) { router.patch( { path: CASE_CONFIGURE_URL, @@ -39,79 +25,15 @@ export function initPatchCaseConfigure({ }, async (context, request, response) => { try { - let error = null; - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); const query = pipe( CasesConfigurePatchRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const myCaseConfigure = await caseConfigureService.find({ soClient }); - const { version, connector, ...queryWithoutVersion } = query; - if (myCaseConfigure.saved_objects.length === 0) { - throw Boom.conflict( - 'You can not patch this configuration since you did not created first with a post.' - ); - } - - if (version !== myCaseConfigure.saved_objects[0].version) { - throw Boom.conflict( - 'This configuration has been updated. Please refresh before saving additional updates.' - ); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); + const client = await context.cases.getCasesClient(); - const updateDate = new Date().toISOString(); - - let mappings: ConnectorMappingsAttributes[] = []; - if (connector != null) { - if (!context.cases) { - throw Boom.badRequest('RouteHandlerContext is not registered for cases'); - } - const casesClient = await context.cases.getCasesClient(); - const actionsClient = context.actions?.getActionsClient(); - if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); - } - try { - mappings = await casesClient.casesClientInternal.configuration.getMappings({ - actionsClient, - connectorId: connector.id, - connectorType: connector.type, - }); - } catch (e) { - error = e.isBoom - ? e.output.payload.message - : `Error connecting to ${connector.name} instance`; - } - } - const patch = await caseConfigureService.patch({ - soClient, - caseConfigureId: myCaseConfigure.saved_objects[0].id, - updatedAttributes: { - ...queryWithoutVersion, - ...(connector != null - ? { connector: transformCaseConnectorToEsConnector(connector) } - : {}), - updated_at: updateDate, - updated_by: { email, full_name, username }, - }, - }); return response.ok({ - body: CaseConfigureResponseRt.encode({ - ...myCaseConfigure.saved_objects[0].attributes, - ...patch.attributes, - connector: transformESConnectorToCaseConnector( - patch.attributes.connector ?? myCaseConfigure.saved_objects[0].attributes.connector - ), - mappings, - version: patch.version ?? '', - error, - }), + body: await client.configure.update(query), }); } catch (error) { logger.error(`Failed to get patch configure in route: ${error}`); diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts index 62fa7cad324fc..ca25a29d6a1de 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts @@ -10,26 +10,12 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { - CasesConfigureRequestRt, - CaseConfigureResponseRt, - throwErrors, - ConnectorMappingsAttributes, -} from '../../../../../common/api'; +import { CasesConfigureRequestRt, throwErrors } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { wrapError, escapeHatch } from '../../utils'; -import { CASE_CONFIGURE_URL, SAVED_OBJECT_TYPES } from '../../../../../common/constants'; -import { - transformCaseConnectorToEsConnector, - transformESConnectorToCaseConnector, -} from '../helpers'; +import { CASE_CONFIGURE_URL } from '../../../../../common/constants'; -export function initPostCaseConfigure({ - caseConfigureService, - caseService, - router, - logger, -}: RouteDeps) { +export function initPostCaseConfigure({ router, logger }: RouteDeps) { router.post( { path: CASE_CONFIGURE_URL, @@ -39,72 +25,15 @@ export function initPostCaseConfigure({ }, async (context, request, response) => { try { - let error = null; - if (!context.cases) { - throw Boom.badRequest('RouteHandlerContext is not registered for cases'); - } - - const casesClient = await context.cases.getCasesClient(); - const actionsClient = context.actions?.getActionsClient(); - - if (actionsClient == null) { - throw Boom.notFound('Action client not found'); - } - - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - const query = pipe( CasesConfigureRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const myCaseConfigure = await caseConfigureService.find({ soClient }); - if (myCaseConfigure.saved_objects.length > 0) { - await Promise.all( - myCaseConfigure.saved_objects.map((cc) => - caseConfigureService.delete({ soClient, caseConfigureId: cc.id }) - ) - ); - } - // eslint-disable-next-line @typescript-eslint/naming-convention - const { email, full_name, username } = await caseService.getUser({ request }); - - const creationDate = new Date().toISOString(); - let mappings: ConnectorMappingsAttributes[] = []; - try { - mappings = await casesClient.casesClientInternal.configuration.getMappings({ - actionsClient, - connectorId: query.connector.id, - connectorType: query.connector.type, - }); - } catch (e) { - error = e.isBoom - ? e.output.payload.message - : `Error connecting to ${query.connector.name} instance`; - } - const post = await caseConfigureService.post({ - soClient, - attributes: { - ...query, - connector: transformCaseConnectorToEsConnector(query.connector), - created_at: creationDate, - created_by: { email, full_name, username }, - updated_at: null, - updated_by: null, - }, - }); + const client = await context.cases.getCasesClient(); return response.ok({ - body: CaseConfigureResponseRt.encode({ - ...post.attributes, - // Reserve for future implementations - connector: transformESConnectorToCaseConnector(post.attributes.connector), - mappings, - version: post.version ?? '', - error, - }), + body: await client.configure.create(query), }); } catch (error) { logger.error(`Failed to post case configure in route: ${error}`); diff --git a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts index a9be4a314adeb..1784a434292cc 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts @@ -7,54 +7,11 @@ import { schema } from '@kbn/config-schema'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -import { CASES_URL, SAVED_OBJECT_TYPES, ENABLE_CASE_CONNECTOR } from '../../../../common/constants'; -import { CaseService, AttachmentService } from '../../../services'; +import { CASES_URL } from '../../../../common/constants'; -async function deleteSubCases({ - attachmentService, - caseService, - soClient, - caseIds, -}: { - attachmentService: AttachmentService; - caseService: CaseService; - soClient: SavedObjectsClientContract; - caseIds: string[]; -}) { - const subCasesForCaseIds = await caseService.findSubCasesByCaseId({ soClient, ids: caseIds }); - - const subCaseIDs = subCasesForCaseIds.saved_objects.map((subCase) => subCase.id); - const commentsForSubCases = await caseService.getAllSubCaseComments({ - soClient, - id: subCaseIDs, - }); - - // This shouldn't actually delete anything because all the comments should be deleted when comments are deleted - // per case ID - await Promise.all( - commentsForSubCases.saved_objects.map((commentSO) => - attachmentService.delete({ soClient, attachmentId: commentSO.id }) - ) - ); - - await Promise.all( - subCasesForCaseIds.saved_objects.map((subCaseSO) => - caseService.deleteSubCase(soClient, subCaseSO.id) - ) - ); -} - -export function initDeleteCasesApi({ - attachmentService, - caseService, - router, - userActionService, - logger, -}: RouteDeps) { +export function initDeleteCasesApi({ router, logger }: RouteDeps) { router.delete( { path: CASES_URL, @@ -66,73 +23,8 @@ export function initDeleteCasesApi({ }, async (context, request, response) => { try { - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - await Promise.all( - request.query.ids.map((id) => - caseService.deleteCase({ - soClient, - id, - }) - ) - ); - const comments = await Promise.all( - request.query.ids.map((id) => - caseService.getAllCaseComments({ - soClient, - id, - }) - ) - ); - - if (comments.some((c) => c.saved_objects.length > 0)) { - await Promise.all( - comments.map((c) => - Promise.all( - c.saved_objects.map(({ id }) => - attachmentService.delete({ - soClient, - attachmentId: id, - }) - ) - ) - ) - ); - } - - if (ENABLE_CASE_CONNECTOR) { - await deleteSubCases({ - attachmentService, - caseService, - soClient, - caseIds: request.query.ids, - }); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const deleteDate = new Date().toISOString(); - - await userActionService.bulkCreate({ - soClient, - actions: request.query.ids.map((id) => - buildCaseUserActionItem({ - action: 'create', - actionAt: deleteDate, - actionBy: { username, full_name, email }, - caseId: id, - fields: [ - 'comment', - 'description', - 'status', - 'tags', - 'title', - ...(ENABLE_CASE_CONNECTOR ? ['sub_case'] : []), - ], - }) - ), - }); + const client = await context.cases.getCasesClient(); + await client.cases.delete(request.query.ids); return response.noContent(); } catch (error) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts index e48806567e574..9d26fbb90328c 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts @@ -7,10 +7,9 @@ import { schema } from '@kbn/config-schema'; -import Boom from '@hapi/boom'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -import { CASE_DETAILS_URL, ENABLE_CASE_CONNECTOR } from '../../../../common/constants'; +import { CASE_DETAILS_URL } from '../../../../common/constants'; export function initGetCaseApi({ router, logger }: RouteDeps) { router.get( @@ -28,11 +27,6 @@ export function initGetCaseApi({ router, logger }: RouteDeps) { }, async (context, request, response) => { try { - if (!ENABLE_CASE_CONNECTOR && request.query.includeSubCaseComments !== undefined) { - throw Boom.badRequest( - 'The `subCaseId` is not supported when the case connector feature is disabled' - ); - } const casesClient = await context.cases.getCasesClient(); const id = request.params.case_id; diff --git a/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts b/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts deleted file mode 100644 index f7cfebeaea749..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts +++ /dev/null @@ -1,111 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SavedObjectsFindResponse } from 'kibana/server'; -import { - CaseConnector, - ConnectorTypes, - ESCaseConnector, - ESCasesConfigureAttributes, -} from '../../../../common/api'; -import { mockCaseConfigure } from '../__fixtures__'; -import { - transformCaseConnectorToEsConnector, - transformESConnectorToCaseConnector, - getConnectorFromConfiguration, -} from './helpers'; - -describe('helpers', () => { - const caseConnector: CaseConnector = { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }; - - const esCaseConnector: ESCaseConnector = { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], - }; - - const caseConfigure: SavedObjectsFindResponse = { - saved_objects: [{ ...mockCaseConfigure[0], score: 0 }], - total: 1, - per_page: 20, - page: 1, - }; - - describe('transformCaseConnectorToEsConnector', () => { - it('transform correctly', () => { - expect(transformCaseConnectorToEsConnector(caseConnector)).toEqual(esCaseConnector); - }); - - it('transform correctly with null attributes', () => { - // @ts-ignore this is case the connector does not exist for old cases object or configurations - expect(transformCaseConnectorToEsConnector(null)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: [], - }); - }); - }); - - describe('transformESConnectorToCaseConnector', () => { - it('transform correctly', () => { - expect(transformESConnectorToCaseConnector(esCaseConnector)).toEqual(caseConnector); - }); - - it('transform correctly with null attributes', () => { - // @ts-ignore this is case the connector does not exist for old cases object or configurations - expect(transformESConnectorToCaseConnector(null)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }); - }); - }); - - describe('getConnectorFromConfiguration', () => { - it('transform correctly', () => { - expect(getConnectorFromConfiguration(caseConfigure)).toEqual({ - id: '789', - name: 'My connector 3', - type: ConnectorTypes.jira, - fields: null, - }); - }); - - it('transform correctly with no connector', () => { - const caseConfigureNoConnector: SavedObjectsFindResponse = { - ...caseConfigure, - saved_objects: [ - { - ...mockCaseConfigure[0], - // @ts-ignore this is case the connector does not exist for old cases object or configurations - attributes: { ...mockCaseConfigure[0].attributes, connector: null }, - score: 0, - }, - ], - }; - - expect(getConnectorFromConfiguration(caseConfigureNoConnector)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }); - }); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts index 1ce60442ee9c9..2836c7572e810 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts @@ -5,12 +5,11 @@ * 2.0. */ -import { UsersRt } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_REPORTERS_URL, SAVED_OBJECT_TYPES } from '../../../../../common/constants'; +import { CASE_REPORTERS_URL } from '../../../../../common/constants'; -export function initGetReportersApi({ caseService, router, logger }: RouteDeps) { +export function initGetReportersApi({ router, logger }: RouteDeps) { router.get( { path: CASE_REPORTERS_URL, @@ -18,13 +17,9 @@ export function initGetReportersApi({ caseService, router, logger }: RouteDeps) }, async (context, request, response) => { try { - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - const reporters = await caseService.getReporters({ - soClient, - }); - return response.ok({ body: UsersRt.encode(reporters) }); + const client = await context.cases.getCasesClient(); + + return response.ok({ body: await client.cases.getReporters() }); } catch (error) { logger.error(`Failed to get reporters in route: ${error}`); return response.customError(wrapError(error)); diff --git a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts index ddfa5e39c01b0..6ba5963580782 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts @@ -8,11 +8,9 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_STATUS_URL, SAVED_OBJECT_TYPES } from '../../../../../common/constants'; -import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; -import { constructQueryOptions } from '../helpers'; +import { CASE_STATUS_URL } from '../../../../../common/constants'; -export function initGetCasesStatusApi({ caseService, router, logger }: RouteDeps) { +export function initGetCasesStatusApi({ router, logger }: RouteDeps) { router.get( { path: CASE_STATUS_URL, @@ -20,27 +18,10 @@ export function initGetCasesStatusApi({ caseService, router, logger }: RouteDeps }, async (context, request, response) => { try { - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - - const [openCases, inProgressCases, closedCases] = await Promise.all([ - ...caseStatuses.map((status) => { - const statusQuery = constructQueryOptions({ status }); - return caseService.findCaseStatusStats({ - soClient, - caseOptions: statusQuery.case, - subCaseOptions: statusQuery.subCase, - }); - }), - ]); + const client = await context.cases.getCasesClient(); return response.ok({ - body: CasesStatusResponseRt.encode({ - count_open_cases: openCases, - count_in_progress_cases: inProgressCases, - count_closed_cases: closedCases, - }), + body: await client.stats.getStatusTotalsByType(), }); } catch (error) { logger.error(`Failed to get status stats in route: ${error}`); diff --git a/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts b/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts index 10c15d2518f34..e13974b514c08 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts @@ -7,9 +7,9 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -import { CASE_TAGS_URL, SAVED_OBJECT_TYPES } from '../../../../../common/constants'; +import { CASE_TAGS_URL } from '../../../../../common/constants'; -export function initGetTagsApi({ caseService, router }: RouteDeps) { +export function initGetTagsApi({ router, logger }: RouteDeps) { router.get( { path: CASE_TAGS_URL, @@ -17,14 +17,11 @@ export function initGetTagsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { - const soClient = context.core.savedObjects.getClient({ - includedHiddenTypes: SAVED_OBJECT_TYPES, - }); - const tags = await caseService.getTags({ - soClient, - }); - return response.ok({ body: tags }); + const client = await context.cases.getCasesClient(); + + return response.ok({ body: await client.cases.getTags() }); } catch (error) { + logger.error(`Failed to retrieve tags in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/cases/server/routes/api/types.ts b/x-pack/plugins/cases/server/routes/api/types.ts index 76fad3fcc33bc..d41e89dae31f8 100644 --- a/x-pack/plugins/cases/server/routes/api/types.ts +++ b/x-pack/plugins/cases/server/routes/api/types.ts @@ -27,12 +27,6 @@ export interface RouteDeps { logger: Logger; } -export enum SortFieldCase { - closedAt = 'closed_at', - createdAt = 'created_at', - status = 'status', -} - export interface TotalCommentByCase { caseId: string; totalComments: number; diff --git a/x-pack/plugins/cases/server/routes/api/utils.test.ts b/x-pack/plugins/cases/server/routes/api/utils.test.ts index 99d2c1509538c..3fce38b27446e 100644 --- a/x-pack/plugins/cases/server/routes/api/utils.test.ts +++ b/x-pack/plugins/cases/server/routes/api/utils.test.ts @@ -5,317 +5,10 @@ * 2.0. */ -import { - transformNewCase, - transformNewComment, - wrapError, - transformCases, - flattenCaseSavedObject, - flattenCommentSavedObjects, - transformComments, - flattenCommentSavedObject, - sortToSnake, -} from './utils'; -import { newCase } from './__mocks__/request_responses'; +import { wrapError } from './utils'; import { isBoom, boomify } from '@hapi/boom'; -import { - mockCases, - mockCaseComments, - mockCaseNoConnectorId, -} from './__fixtures__/mock_saved_objects'; -import { - ConnectorTypes, - ESCaseConnector, - CommentType, - AssociationType, - CaseType, - CaseResponse, -} from '../../../common/api'; describe('Utils', () => { - describe('transformNewCase', () => { - const connector: ESCaseConnector = { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], - }; - it('transform correctly', () => { - const myCase = { - newCase: { ...newCase, type: CaseType.individual }, - connector, - createdDate: '2020-04-09T09:43:51.778Z', - email: 'elastic@elastic.co', - full_name: 'Elastic', - username: 'elastic', - }; - - const res = transformNewCase(myCase); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": "elastic@elastic.co", - "full_name": "Elastic", - "username": "elastic", - }, - "description": "A description", - "external_service": null, - "owner": "securitySolution", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "tags": Array [ - "new", - "case", - ], - "title": "My new case", - "type": "individual", - "updated_at": null, - "updated_by": null, - } - `); - }); - - it('transform correctly without optional fields', () => { - const myCase = { - newCase: { ...newCase, type: CaseType.individual }, - connector, - createdDate: '2020-04-09T09:43:51.778Z', - }; - - const res = transformNewCase(myCase); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": undefined, - "full_name": undefined, - "username": undefined, - }, - "description": "A description", - "external_service": null, - "owner": "securitySolution", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "tags": Array [ - "new", - "case", - ], - "title": "My new case", - "type": "individual", - "updated_at": null, - "updated_by": null, - } - `); - }); - - it('transform correctly with optional fields as null', () => { - const myCase = { - newCase: { ...newCase, type: CaseType.individual }, - connector, - createdDate: '2020-04-09T09:43:51.778Z', - email: null, - full_name: null, - username: null, - }; - - const res = transformNewCase(myCase); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "description": "A description", - "external_service": null, - "owner": "securitySolution", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "tags": Array [ - "new", - "case", - ], - "title": "My new case", - "type": "individual", - "updated_at": null, - "updated_by": null, - } - `); - }); - }); - - describe('transformNewComment', () => { - it('transforms correctly', () => { - const comment = { - comment: 'A comment', - type: CommentType.user as const, - createdDate: '2020-04-09T09:43:51.778Z', - email: 'elastic@elastic.co', - full_name: 'Elastic', - username: 'elastic', - associationType: AssociationType.case, - }; - - const res = transformNewComment(comment); - expect(res).toMatchInlineSnapshot(` - Object { - "associationType": "case", - "comment": "A comment", - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": "elastic@elastic.co", - "full_name": "Elastic", - "username": "elastic", - }, - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - } - `); - }); - - it('transform correctly without optional fields', () => { - const comment = { - comment: 'A comment', - type: CommentType.user as const, - createdDate: '2020-04-09T09:43:51.778Z', - associationType: AssociationType.case, - }; - - const res = transformNewComment(comment); - - expect(res).toMatchInlineSnapshot(` - Object { - "associationType": "case", - "comment": "A comment", - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": undefined, - "full_name": undefined, - "username": undefined, - }, - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - } - `); - }); - - it('transform correctly with optional fields as null', () => { - const comment = { - comment: 'A comment', - type: CommentType.user as const, - createdDate: '2020-04-09T09:43:51.778Z', - email: null, - full_name: null, - username: null, - associationType: AssociationType.case, - }; - - const res = transformNewComment(comment); - - expect(res).toMatchInlineSnapshot(` - Object { - "associationType": "case", - "comment": "A comment", - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - } - `); - }); - }); - describe('wrapError', () => { it('wraps an error', () => { const error = new Error('Something happened'); @@ -361,539 +54,4 @@ describe('Utils', () => { expect(res.headers).toEqual({}); }); }); - - describe('transformCases', () => { - it('transforms correctly', () => { - const casesMap = new Map( - mockCases.map((obj) => { - return [obj.id, flattenCaseSavedObject({ savedObject: obj, totalComment: 2 })]; - }) - ); - const res = transformCases({ - casesMap, - countOpenCases: 2, - countInProgressCases: 2, - countClosedCases: 2, - page: 1, - perPage: 10, - total: casesMap.size, - }); - expect(res).toMatchInlineSnapshot(` - Object { - "cases": Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "owner": "securitySolution", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - }, - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T22:32:00.900Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie destroying data!", - "external_service": null, - "id": "mock-id-2", - "owner": "securitySolution", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "Data Destruction", - ], - "title": "Damaging Data Destruction Detected", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:00.900Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzQsMV0=", - }, - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "owner": "securitySolution", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", - }, - Object { - "closed_at": "2019-11-25T22:32:17.947Z", - "closed_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-4", - "owner": "securitySolution", - "settings": Object { - "syncAlerts": true, - }, - "status": "closed", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", - }, - ], - "count_closed_cases": 2, - "count_in_progress_cases": 2, - "count_open_cases": 2, - "page": 1, - "per_page": 10, - "total": 4, - } - `); - }); - }); - - describe('flattenCaseSavedObject', () => { - it('flattens correctly', () => { - const myCase = { ...mockCases[2] }; - const res = flattenCaseSavedObject({ - savedObject: myCase, - totalComment: 2, - }); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "owner": "securitySolution", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", - } - `); - }); - - it('flattens correctly without version', () => { - const myCase = { ...mockCases[2] }; - myCase.version = undefined; - const res = flattenCaseSavedObject({ - savedObject: myCase, - totalComment: 2, - }); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "owner": "securitySolution", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "0", - } - `); - }); - - it('flattens correctly with comments', () => { - const myCase = { ...mockCases[2] }; - const comments = [{ ...mockCaseComments[0] }]; - const res = flattenCaseSavedObject({ - savedObject: myCase, - comments, - totalComment: 2, - }); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [ - Object { - "associationType": "case", - "comment": "Wow, good luck catching that bad meanie!", - "created_at": "2019-11-25T21:55:00.177Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "id": "mock-comment-1", - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": "2019-11-25T21:55:00.177Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzEsMV0=", - }, - ], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "owner": "securitySolution", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", - } - `); - }); - - it('inserts missing connector', () => { - const extraCaseData = { - totalComment: 2, - }; - - const res = flattenCaseSavedObject({ - // @ts-ignore this is to update old case saved objects to include connector - savedObject: mockCaseNoConnectorId, - ...extraCaseData, - }); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-no-connector_id", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 2, - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - } - `); - }); - }); - - describe('transformComments', () => { - it('transforms correctly', () => { - const comments = { - saved_objects: mockCaseComments.map((obj) => ({ ...obj, score: 1 })), - total: mockCaseComments.length, - per_page: 10, - page: 1, - }; - - const res = transformComments(comments); - expect(res).toEqual({ - page: 1, - per_page: 10, - total: mockCaseComments.length, - comments: flattenCommentSavedObjects(comments.saved_objects), - }); - }); - }); - - describe('flattenCommentSavedObjects', () => { - it('flattens correctly', () => { - const comments = [{ ...mockCaseComments[0] }, { ...mockCaseComments[1] }]; - const res = flattenCommentSavedObjects(comments); - expect(res).toEqual([ - flattenCommentSavedObject(comments[0]), - flattenCommentSavedObject(comments[1]), - ]); - }); - }); - - describe('flattenCommentSavedObject', () => { - it('flattens correctly', () => { - const comment = { ...mockCaseComments[0] }; - const res = flattenCommentSavedObject(comment); - expect(res).toEqual({ - id: comment.id, - version: comment.version, - ...comment.attributes, - }); - }); - - it('flattens correctly without version', () => { - const comment = { ...mockCaseComments[0] }; - comment.version = undefined; - const res = flattenCommentSavedObject(comment); - expect(res).toEqual({ - id: comment.id, - version: '0', - ...comment.attributes, - }); - }); - }); - - describe('sortToSnake', () => { - it('it transforms status correctly', () => { - expect(sortToSnake('status')).toBe('status'); - }); - - it('it transforms createdAt correctly', () => { - expect(sortToSnake('createdAt')).toBe('created_at'); - }); - - it('it transforms created_at correctly', () => { - expect(sortToSnake('created_at')).toBe('created_at'); - }); - - it('it transforms closedAt correctly', () => { - expect(sortToSnake('closedAt')).toBe('closed_at'); - }); - - it('it transforms closed_at correctly', () => { - expect(sortToSnake('closed_at')).toBe('closed_at'); - }); - - it('it transforms default correctly', () => { - expect(sortToSnake('not-exist')).toBe('created_at'); - }); - }); }); diff --git a/x-pack/plugins/cases/server/routes/api/utils.ts b/x-pack/plugins/cases/server/routes/api/utils.ts index 8e8862f4157f1..f7a77a5dbf391 100644 --- a/x-pack/plugins/cases/server/routes/api/utils.ts +++ b/x-pack/plugins/cases/server/routes/api/utils.ts @@ -5,180 +5,12 @@ * 2.0. */ -import { isEmpty } from 'lodash'; -import { badRequest, Boom, boomify, isBoom } from '@hapi/boom'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { schema } from '@kbn/config-schema'; -import { - CustomHttpResponseOptions, - ResponseError, - SavedObject, - SavedObjectsFindResponse, -} from 'kibana/server'; - -import { - CaseResponse, - CasesFindResponse, - CommentResponse, - CommentsResponse, - CommentAttributes, - ESCaseConnector, - ESCaseAttributes, - CommentRequest, - ContextTypeUserRt, - CommentRequestUserType, - CommentRequestAlertType, - CommentType, - excess, - throwErrors, - CaseStatuses, - CasesClientPostRequest, - AssociationType, - SubCaseAttributes, - SubCaseResponse, - SubCasesFindResponse, - User, - AlertCommentRequestRt, -} from '../../../common/api'; -import { transformESConnectorToCaseConnector } from './cases/helpers'; +import { Boom, boomify, isBoom } from '@hapi/boom'; -import { SortFieldCase } from './types'; -import { AlertInfo } from '../../common'; +import { schema } from '@kbn/config-schema'; +import { CustomHttpResponseOptions, ResponseError } from 'kibana/server'; import { isCaseError } from '../../common/error'; -export const transformNewSubCase = ({ - createdAt, - createdBy, -}: { - createdAt: string; - createdBy: User; -}): SubCaseAttributes => { - return { - closed_at: null, - closed_by: null, - created_at: createdAt, - created_by: createdBy, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }; -}; - -export const transformNewCase = ({ - connector, - createdDate, - email, - // eslint-disable-next-line @typescript-eslint/naming-convention - full_name, - newCase, - username, -}: { - connector: ESCaseConnector; - createdDate: string; - email?: string | null; - full_name?: string | null; - newCase: CasesClientPostRequest; - username?: string | null; -}): ESCaseAttributes => ({ - ...newCase, - closed_at: null, - closed_by: null, - connector, - created_at: createdDate, - created_by: { email, full_name, username }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, -}); - -type NewCommentArgs = CommentRequest & { - associationType: AssociationType; - createdDate: string; - email?: string | null; - full_name?: string | null; - username?: string | null; -}; - -/** - * Return the alert IDs from the comment if it is an alert style comment. Otherwise return an empty array. - */ -export const getAlertIds = (comment: CommentRequest): string[] => { - if (isCommentRequestTypeAlertOrGenAlert(comment)) { - return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; - } - return []; -}; - -const getIDsAndIndicesAsArrays = ( - comment: CommentRequestAlertType -): { ids: string[]; indices: string[] } => { - return { - ids: Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId], - indices: Array.isArray(comment.index) ? comment.index : [comment.index], - }; -}; - -/** - * This functions extracts the ids and indices from an alert comment. It enforces that the alertId and index are either - * both strings or string arrays that are the same length. If they are arrays they represent a 1-to-1 mapping of - * id existing in an index at each position in the array. This is not ideal. Ideally an alert comment request would - * accept an array of objects like this: Array<{id: string; index: string; ruleName: string ruleID: string}> instead. - * - * To reformat the alert comment request requires a migration and a breaking API change. - */ -const getAndValidateAlertInfoFromComment = (comment: CommentRequest): AlertInfo[] => { - if (!isCommentRequestTypeAlertOrGenAlert(comment)) { - return []; - } - - const { ids, indices } = getIDsAndIndicesAsArrays(comment); - - if (ids.length !== indices.length) { - return []; - } - - return ids.map((id, index) => ({ id, index: indices[index] })); -}; - -/** - * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alerts. - */ -export const getAlertInfoFromComments = (comments: CommentRequest[] | undefined): AlertInfo[] => { - if (comments === undefined) { - return []; - } - - return comments.reduce((acc: AlertInfo[], comment) => { - const alertInfo = getAndValidateAlertInfoFromComment(comment); - acc.push(...alertInfo); - return acc; - }, []); -}; - -export const transformNewComment = ({ - associationType, - createdDate, - email, - // eslint-disable-next-line @typescript-eslint/naming-convention - full_name, - username, - ...comment -}: NewCommentArgs): CommentAttributes => { - return { - associationType, - ...comment, - created_at: createdDate, - created_by: { email, full_name, username }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }; -}; - /** * Transforms an error into the correct format for a kibana response. */ @@ -199,222 +31,4 @@ export function wrapError(error: any): CustomHttpResponseOptions }; } -export const transformCases = ({ - casesMap, - countOpenCases, - countInProgressCases, - countClosedCases, - page, - perPage, - total, -}: { - casesMap: Map; - countOpenCases: number; - countInProgressCases: number; - countClosedCases: number; - page: number; - perPage: number; - total: number; -}): CasesFindResponse => ({ - page, - per_page: perPage, - total, - cases: Array.from(casesMap.values()), - count_open_cases: countOpenCases, - count_in_progress_cases: countInProgressCases, - count_closed_cases: countClosedCases, -}); - -export const transformSubCases = ({ - subCasesMap, - open, - inProgress, - closed, - page, - perPage, - total, -}: { - subCasesMap: Map; - open: number; - inProgress: number; - closed: number; - page: number; - perPage: number; - total: number; -}): SubCasesFindResponse => ({ - page, - per_page: perPage, - total, - // Squish all the entries in the map together as one array - subCases: Array.from(subCasesMap.values()).flat(), - count_open_cases: open, - count_in_progress_cases: inProgress, - count_closed_cases: closed, -}); - -export const flattenCaseSavedObject = ({ - savedObject, - comments = [], - totalComment = comments.length, - totalAlerts = 0, - subCases, - subCaseIds, -}: { - savedObject: SavedObject; - comments?: Array>; - totalComment?: number; - totalAlerts?: number; - subCases?: SubCaseResponse[]; - subCaseIds?: string[]; -}): CaseResponse => ({ - id: savedObject.id, - version: savedObject.version ?? '0', - comments: flattenCommentSavedObjects(comments), - totalComment, - totalAlerts, - ...savedObject.attributes, - connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), - subCases, - subCaseIds: !isEmpty(subCaseIds) ? subCaseIds : undefined, -}); - -export const flattenSubCaseSavedObject = ({ - savedObject, - comments = [], - totalComment = comments.length, - totalAlerts = 0, -}: { - savedObject: SavedObject; - comments?: Array>; - totalComment?: number; - totalAlerts?: number; -}): SubCaseResponse => ({ - id: savedObject.id, - version: savedObject.version ?? '0', - comments: flattenCommentSavedObjects(comments), - totalComment, - totalAlerts, - ...savedObject.attributes, -}); - -export const transformComments = ( - comments: SavedObjectsFindResponse -): CommentsResponse => ({ - page: comments.page, - per_page: comments.per_page, - total: comments.total, - comments: flattenCommentSavedObjects(comments.saved_objects), -}); - -export const flattenCommentSavedObjects = ( - savedObjects: Array> -): CommentResponse[] => - savedObjects.reduce((acc: CommentResponse[], savedObject: SavedObject) => { - return [...acc, flattenCommentSavedObject(savedObject)]; - }, []); - -export const flattenCommentSavedObject = ( - savedObject: SavedObject -): CommentResponse => ({ - id: savedObject.id, - version: savedObject.version ?? '0', - ...savedObject.attributes, -}); - -export const sortToSnake = (sortField: string | undefined): SortFieldCase => { - switch (sortField) { - case 'status': - return SortFieldCase.status; - case 'createdAt': - case 'created_at': - return SortFieldCase.createdAt; - case 'closedAt': - case 'closed_at': - return SortFieldCase.closedAt; - default: - return SortFieldCase.createdAt; - } -}; - export const escapeHatch = schema.object({}, { unknowns: 'allow' }); - -/** - * A type narrowing function for user comments. Exporting so integration tests can use it. - */ -export const isCommentRequestTypeUser = ( - context: CommentRequest -): context is CommentRequestUserType => { - return context.type === CommentType.user; -}; - -/** - * A type narrowing function for alert comments. Exporting so integration tests can use it. - */ -export const isCommentRequestTypeAlertOrGenAlert = ( - context: CommentRequest -): context is CommentRequestAlertType => { - return context.type === CommentType.alert || context.type === CommentType.generatedAlert; -}; - -/** - * This is used to test if the posted comment is an generated alert. A generated alert will have one or many alerts. - * An alert is essentially an object with a _id field. This differs from a regular attached alert because the _id is - * passed directly in the request, it won't be in an object. Internally case will strip off the outer object and store - * both a generated and user attached alert in the same structure but this function is useful to determine which - * structure the new alert in the request has. - */ -export const isCommentRequestTypeGenAlert = ( - context: CommentRequest -): context is CommentRequestAlertType => { - return context.type === CommentType.generatedAlert; -}; - -export const decodeCommentRequest = (comment: CommentRequest) => { - if (isCommentRequestTypeUser(comment)) { - pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isCommentRequestTypeAlertOrGenAlert(comment)) { - pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); - const { ids, indices } = getIDsAndIndicesAsArrays(comment); - - /** - * The alertId and index field must either be both of type string or they must both be string[] and be the same length. - * Having a one-to-one relationship between the id and index of an alert avoids accidentally updating or - * retrieving the wrong alert. Elasticsearch only guarantees that the _id (the field we use for alertId) to be - * unique within a single index. So if we attempt to update or get a specific alert across multiple indices we could - * update or receive the wrong one. - * - * Consider the situation where we have a alert1 with _id = '100' in index 'my-index-awesome' and also in index - * 'my-index-hi'. - * If we attempt to update the status of alert1 using an index pattern like `my-index-*` or even providing multiple - * indices, there's a chance we'll accidentally update too many alerts. - * - * This check doesn't enforce that the API request has the correct alert ID to index relationship it just guards - * against accidentally making a request like: - * { - * alertId: [1,2,3], - * index: awesome, - * } - * - * Instead this requires the requestor to provide: - * { - * alertId: [1,2,3], - * index: [awesome, awesome, awesome] - * } - * - * Ideally we'd change the format of the comment request to be an array of objects like: - * { - * alerts: [{id: 1, index: awesome}, {id: 2, index: awesome}] - * } - * - * But we'd need to also implement a migration because the saved object document currently stores the id and index - * in separate fields. - */ - if (ids.length !== indices.length) { - throw badRequest( - `Received an alert comment with ids and indices arrays of different lengths ids: ${JSON.stringify( - ids - )} indices: ${JSON.stringify(indices)}` - ); - } - } -}; diff --git a/x-pack/plugins/cases/server/services/cases/index.ts b/x-pack/plugins/cases/server/services/cases/index.ts index 99d6129dc54b3..c7d94b3c66329 100644 --- a/x-pack/plugins/cases/server/services/cases/index.ts +++ b/x-pack/plugins/cases/server/services/cases/index.ts @@ -31,19 +31,17 @@ import { CaseResponse, caseTypeField, CasesFindRequest, + CaseStatuses, } from '../../../common/api'; import { defaultSortField, + flattenCaseSavedObject, + flattenSubCaseSavedObject, groupTotalAlertsByID, SavedObjectFindOptionsKueryNode, } from '../../common'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; import { defaultPage, defaultPerPage } from '../../routes/api'; -import { - flattenCaseSavedObject, - flattenSubCaseSavedObject, - transformNewSubCase, -} from '../../routes/api/utils'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT, @@ -174,6 +172,24 @@ interface CasesMapWithPageInfo { type FindCaseOptions = CasesFindRequest & SavedObjectFindOptionsKueryNode; +const transformNewSubCase = ({ + createdAt, + createdBy, +}: { + createdAt: string; + createdBy: User; +}): SubCaseAttributes => { + return { + closed_at: null, + closed_by: null, + created_at: createdAt, + created_by: createdBy, + status: CaseStatuses.open, + updated_at: null, + updated_by: null, + }; +}; + export class CaseService { constructor( private readonly log: Logger, diff --git a/x-pack/plugins/cases/server/services/user_actions/helpers.ts b/x-pack/plugins/cases/server/services/user_actions/helpers.ts index ebfdcd9792f31..e987bd1685405 100644 --- a/x-pack/plugins/cases/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/cases/server/services/user_actions/helpers.ts @@ -18,16 +18,14 @@ import { UserActionFieldType, SubCaseAttributes, } from '../../../common/api'; -import { - isTwoArraysDifference, - transformESConnectorToCaseConnector, -} from '../../routes/api/cases/helpers'; +import { isTwoArraysDifference } from '../../client/utils'; import { UserActionItem } from '.'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT, } from '../../../common/constants'; +import { transformESConnectorToCaseConnector } from '../../common'; export const transformNewUserAction = ({ actionField, diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/observability/kibana.json b/x-pack/test/case_api_integration/common/fixtures/plugins/observability/kibana.json index b4b540fc9a821..5115f4e3a0d3b 100644 --- a/x-pack/test/case_api_integration/common/fixtures/plugins/observability/kibana.json +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/observability/kibana.json @@ -3,7 +3,7 @@ "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], - "requiredPlugins": ["features"], + "requiredPlugins": ["features", "cases"], "optionalPlugins": ["security", "spaces"], "server": true, "ui": false diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/kibana.json b/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/kibana.json index 000848e771af3..cdef22263b01e 100644 --- a/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/kibana.json +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/kibana.json @@ -3,7 +3,7 @@ "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], - "requiredPlugins": ["features"], + "requiredPlugins": ["features", "cases"], "optionalPlugins": ["security", "spaces"], "server": true, "ui": false diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 32094e60832a9..2ff5e9d71985b 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -614,7 +614,9 @@ export const deleteCases = async ({ }) => { const { body } = await supertest .delete(`${CASES_URL}`) - .query({ ids: caseIDs }) + // we need to json stringify here because just passing in the array of case IDs will cause a 400 with Kibana + // not being able to parse the array correctly. The format ids=["1", "2"] seems to work, which stringify outputs. + .query({ ids: JSON.stringify(caseIDs) }) .set('kbn-xsrf', 'true') .send() .expect(expectedHttpCode); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/get_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/get_case.ts index ca16416991cbf..8239cbadbaa2f 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/get_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/get_case.ts @@ -72,7 +72,7 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(400); - expect(body.message).to.contain('subCaseId'); + expect(body.message).to.contain('disabled'); }); it('unhappy path - 404s when case is not there', async () => { diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/delete_comment.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/delete_comment.ts index 8394109ce6696..cd4e72f6f9315 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/delete_comment.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/delete_comment.ts @@ -72,7 +72,7 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(400); // make sure the failure is because of the subCaseId - expect(body.message).to.contain('subCaseId'); + expect(body.message).to.contain('disabled'); }); it('should return a 400 when attempting to delete a single comment when passing the `subCaseId` parameter', async () => { @@ -82,7 +82,7 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(400); // make sure the failure is because of the subCaseId - expect(body.message).to.contain('subCaseId'); + expect(body.message).to.contain('disabled'); }); }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/find_comments.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/find_comments.ts index 95f15d1e330ff..43e128c1e41fa 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/find_comments.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/find_comments.ts @@ -118,7 +118,7 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(400); - expect(body.message).to.contain('subCaseId'); + expect(body.message).to.contain('disabled'); }); // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts index 06eb9d0fb4174..736d04f43ed05 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts @@ -47,7 +47,7 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(400); - expect(body.message).to.contain('subCaseId'); + expect(body.message).to.contain('disabled'); }); it('should return a 400 when passing the includeSubCaseComments parameter', async () => { @@ -57,7 +57,7 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(400); - expect(body.message).to.contain('includeSubCaseComments'); + expect(body.message).to.contain('disabled'); }); // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_comment.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_comment.ts index e843b31d18dfd..441f01843f865 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_comment.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_comment.ts @@ -8,7 +8,6 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; import { createCaseAction, diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts index b82800b6bd7a6..b73b89d33e9c6 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts @@ -21,7 +21,6 @@ import { postCaseReq, postCommentUserReq, postCommentAlertReq, - postCommentGenAlertReq, } from '../../../../common/lib/mock'; import { createCaseAction, @@ -65,7 +64,7 @@ export default ({ getService }: FtrProviderContext): void => { }) .expect(400); - expect(body.message).to.contain('subCaseId'); + expect(body.message).to.contain('disabled'); }); // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts index 0d11edc5587d1..56a6d1b15004b 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts @@ -31,7 +31,7 @@ export default ({ getService }: FtrProviderContext): void => { await deleteCasesUserActions(es); }); - it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector', 'settings]`, async () => { + it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector', 'settings, owner]`, async () => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -53,6 +53,7 @@ export default ({ getService }: FtrProviderContext): void => { 'title', 'connector', 'settings', + 'owner', ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null);