diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index bebd261fb7b9b..49643ca1f4d0c 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -10,22 +10,21 @@ import * as rt from 'io-ts'; import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; -import { CasesStatusResponseRt } from './status'; +import { CasesStatusResponseRt, CaseStatusRt } from './status'; import { CaseConnectorRt, ESCaseConnector } from '../connectors'; +import { SubCaseResponseRt } from './sub_case'; -export enum CaseStatuses { - open = 'open', - 'in-progress' = 'in-progress', - closed = 'closed', +export enum CaseType { + collection = 'collection', + individual = 'individual', } -const CaseStatusRt = rt.union([ - rt.literal(CaseStatuses.open), - rt.literal(CaseStatuses['in-progress']), - rt.literal(CaseStatuses.closed), -]); +/** + * Exposing the field used to define the case type so that it can be used for filtering in saved object find queries. + */ +export const caseTypeField = 'type'; -export const caseStatuses = Object.values(CaseStatuses); +const CaseTypeRt = rt.union([rt.literal(CaseType.collection), rt.literal(CaseType.individual)]); const SettingsRt = rt.type({ syncAlerts: rt.boolean, @@ -36,6 +35,7 @@ const CaseBasicRt = rt.type({ status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, + [caseTypeField]: CaseTypeRt, connector: CaseConnectorRt, settings: SettingsRt, }); @@ -72,7 +72,7 @@ export const CaseAttributesRt = rt.intersection([ }), ]); -export const CasePostRequestRt = rt.type({ +const CasePostRequestNoTypeRt = rt.type({ description: rt.string, tags: rt.array(rt.string), title: rt.string, @@ -80,7 +80,27 @@ export const CasePostRequestRt = rt.type({ settings: SettingsRt, }); +/** + * This type is used for validating a create case request. It requires that the type field be defined. + */ +export const CaseClientPostRequestRt = rt.type({ + ...CasePostRequestNoTypeRt.props, + [caseTypeField]: CaseTypeRt, +}); + +/** + * This type is not used for validation when decoding a request because intersection does not have props defined which + * required for the excess function. Instead we use this as the type used by the UI. This allows the type field to be + * optional and the server will handle setting it to a default value before validating that the request + * has all the necessary fields. CaseClientPostRequestRt is used for validation. + */ +export const CasePostRequestRt = rt.intersection([ + rt.partial({ type: CaseTypeRt }), + CasePostRequestNoTypeRt, +]); + export const CasesFindRequestRt = rt.partial({ + type: CaseTypeRt, tags: rt.union([rt.array(rt.string), rt.string]), status: CaseStatusRt, reporters: rt.union([rt.array(rt.string), rt.string]), @@ -99,9 +119,11 @@ export const CaseResponseRt = rt.intersection([ rt.type({ id: rt.string, totalComment: rt.number, + totalAlerts: rt.number, version: rt.string, }), rt.partial({ + subCases: rt.array(SubCaseResponseRt), comments: rt.array(CommentResponseRt), }), ]); @@ -150,13 +172,21 @@ export const ExternalServiceResponseRt = rt.intersection([ ]); export type CaseAttributes = rt.TypeOf; +/** + * This field differs from the CasePostRequest in that the post request's type field can be optional. This type requires + * that the type field be defined. The CasePostRequest should be used in most places (the UI etc). This type is really + * only necessary for validation. + */ +export type CaseClientPostRequest = rt.TypeOf; export type CasePostRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; export type CasesResponse = rt.TypeOf; +export type CasesFindRequest = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; export type CaseFullExternalService = rt.TypeOf; +export type CaseSettings = rt.TypeOf; export type ExternalServiceResponse = rt.TypeOf; export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 7c9b31f496e54..cfc6099fa4bb5 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -9,7 +9,22 @@ import * as rt from 'io-ts'; import { UserRT } from '../user'; +/** + * this is used to differentiate between an alert attached to a top-level case and a group of alerts that should only + * be attached to a sub case. The reason we need this is because an alert group comment will have references to both a case and + * sub case when it is created. For us to be able to filter out alert groups in a top-level case we need a field to + * use as a filter. + */ +export enum AssociationType { + case = 'case', + subCase = 'sub_case', +} + export const CommentAttributesBasicRt = rt.type({ + associationType: rt.union([ + rt.literal(AssociationType.case), + rt.literal(AssociationType.subCase), + ]), created_at: rt.string, created_by: UserRT, pushed_at: rt.union([rt.string, rt.null]), @@ -18,24 +33,33 @@ export const CommentAttributesBasicRt = rt.type({ updated_by: rt.union([UserRT, rt.null]), }); +export enum CommentType { + user = 'user', + alert = 'alert', + generatedAlert = 'generated_alert', +} + export const ContextTypeUserRt = rt.type({ comment: rt.string, - type: rt.literal('user'), + type: rt.literal(CommentType.user), }); -export const ContextTypeAlertRt = rt.type({ - type: rt.literal('alert'), - alertId: rt.string, +/** + * This defines the structure of how alerts (generated or user attached) are stored in saved objects documents. It also + * represents of an alert after it has been transformed. A generated alert will be transformed by the connector so that + * it matches this structure. User attached alerts do not need to be transformed. + */ +export const AlertCommentRequestRt = rt.type({ + type: rt.union([rt.literal(CommentType.generatedAlert), rt.literal(CommentType.alert)]), + alertId: rt.union([rt.array(rt.string), rt.string]), index: rt.string, }); const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); -const AttributesTypeAlertsRt = rt.intersection([ContextTypeAlertRt, CommentAttributesBasicRt]); +const AttributesTypeAlertsRt = rt.intersection([AlertCommentRequestRt, CommentAttributesBasicRt]); const CommentAttributesRt = rt.union([AttributesTypeUserRt, AttributesTypeAlertsRt]); -const ContextBasicRt = rt.union([ContextTypeUserRt, ContextTypeAlertRt]); - -export const CommentRequestRt = ContextBasicRt; +export const CommentRequestRt = rt.union([ContextTypeUserRt, AlertCommentRequestRt]); export const CommentResponseRt = rt.intersection([ CommentAttributesRt, @@ -60,7 +84,7 @@ export const CommentPatchRequestRt = rt.intersection([ * Partial updates are not allowed. * We want to prevent the user for changing the type without removing invalid fields. */ - ContextBasicRt, + CommentRequestRt, rt.type({ id: rt.string, version: rt.string }), ]); @@ -71,7 +95,7 @@ export const CommentPatchRequestRt = rt.intersection([ * We ensure that partial updates of CommentContext is not going to happen inside the patch comment route. */ export const CommentPatchAttributesRt = rt.intersection([ - rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(ContextTypeAlertRt.props)]), + rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(AlertCommentRequestRt.props)]), rt.partial(CommentAttributesBasicRt.props), ]); @@ -82,11 +106,6 @@ export const CommentsResponseRt = rt.type({ total: rt.number, }); -export enum CommentType { - user = 'user', - alert = 'alert', -} - export const AllCommentsResponseRt = rt.array(CommentResponseRt); export type CommentAttributes = rt.TypeOf; @@ -98,4 +117,4 @@ export type CommentsResponse = rt.TypeOf; export type CommentPatchRequest = rt.TypeOf; export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; -export type CommentRequestAlertType = rt.TypeOf; +export type CommentRequestAlertType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/commentable_case.ts b/x-pack/plugins/case/common/api/cases/commentable_case.ts new file mode 100644 index 0000000000000..023229a90d352 --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/commentable_case.ts @@ -0,0 +1,35 @@ +/* + * 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 * as rt from 'io-ts'; +import { CaseAttributesRt } from './case'; +import { CommentResponseRt } from './comment'; +import { SubCaseAttributesRt, SubCaseResponseRt } from './sub_case'; + +export const CollectionSubCaseAttributesRt = rt.intersection([ + rt.partial({ subCase: SubCaseAttributesRt }), + rt.type({ + case: CaseAttributesRt, + }), +]); + +export const CollectWithSubCaseResponseRt = rt.intersection([ + CaseAttributesRt, + rt.type({ + id: rt.string, + totalComment: rt.number, + version: rt.string, + }), + rt.partial({ + subCase: SubCaseResponseRt, + totalAlerts: rt.number, + comments: rt.array(CommentResponseRt), + }), +]); + +export type CollectionWithSubCaseResponse = rt.TypeOf; +export type CollectionWithSubCaseAttributes = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index a3ad4100b7ce3..4d1fc68109ddb 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -10,3 +10,5 @@ export * from './configure'; export * from './comment'; export * from './status'; export * from './user_actions'; +export * from './sub_case'; +export * from './commentable_case'; diff --git a/x-pack/plugins/case/common/api/cases/status.ts b/x-pack/plugins/case/common/api/cases/status.ts index 2e05930c37f5d..7286e19da9159 100644 --- a/x-pack/plugins/case/common/api/cases/status.ts +++ b/x-pack/plugins/case/common/api/cases/status.ts @@ -7,6 +7,20 @@ import * as rt from 'io-ts'; +export enum CaseStatuses { + open = 'open', + 'in-progress' = 'in-progress', + closed = 'closed', +} + +export const CaseStatusRt = rt.union([ + rt.literal(CaseStatuses.open), + rt.literal(CaseStatuses['in-progress']), + rt.literal(CaseStatuses.closed), +]); + +export const caseStatuses = Object.values(CaseStatuses); + export const CasesStatusResponseRt = rt.type({ count_open_cases: rt.number, count_in_progress_cases: rt.number, diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts new file mode 100644 index 0000000000000..c46f87c547d50 --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +import { NumberFromString } from '../saved_object'; +import { UserRT } from '../user'; +import { CommentResponseRt } from './comment'; +import { CasesStatusResponseRt } from './status'; +import { CaseStatusRt } from './status'; + +const SubCaseBasicRt = rt.type({ + status: CaseStatusRt, +}); + +export const SubCaseAttributesRt = rt.intersection([ + SubCaseBasicRt, + rt.type({ + closed_at: rt.union([rt.string, rt.null]), + closed_by: rt.union([UserRT, rt.null]), + created_at: rt.string, + created_by: rt.union([UserRT, rt.null]), + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRT, rt.null]), + }), +]); + +export const SubCasesFindRequestRt = rt.partial({ + status: CaseStatusRt, + defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + fields: rt.array(rt.string), + page: NumberFromString, + perPage: NumberFromString, + search: rt.string, + searchFields: rt.array(rt.string), + sortField: rt.string, + sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), +}); + +export const SubCaseResponseRt = rt.intersection([ + SubCaseAttributesRt, + rt.type({ + id: rt.string, + totalComment: rt.number, + totalAlerts: rt.number, + version: rt.string, + }), + rt.partial({ + comments: rt.array(CommentResponseRt), + }), +]); + +export const SubCasesFindResponseRt = rt.intersection([ + rt.type({ + subCases: rt.array(SubCaseResponseRt), + page: rt.number, + per_page: rt.number, + total: rt.number, + }), + CasesStatusResponseRt, +]); + +export const SubCasePatchRequestRt = rt.intersection([ + rt.partial(SubCaseBasicRt.props), + rt.type({ id: rt.string, version: rt.string }), +]); + +export const SubCasesPatchRequestRt = rt.type({ subCases: rt.array(SubCasePatchRequestRt) }); +export const SubCasesResponseRt = rt.array(SubCaseResponseRt); + +export type SubCaseAttributes = rt.TypeOf; +export type SubCaseResponse = rt.TypeOf; +export type SubCasesResponse = rt.TypeOf; +export type SubCasesFindResponse = rt.TypeOf; +export type SubCasePatchRequest = rt.TypeOf; +export type SubCasesPatchRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index a83b8e46ae04e..de9e88993df9a 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -12,18 +12,18 @@ import { UserRT } from '../user'; /* To the next developer, if you add/removed fields here * make sure to check this file (x-pack/plugins/case/server/services/user_actions/helpers.ts) too */ -const UserActionFieldRt = rt.array( - rt.union([ - rt.literal('comment'), - rt.literal('connector'), - rt.literal('description'), - rt.literal('pushed'), - rt.literal('tags'), - rt.literal('title'), - rt.literal('status'), - rt.literal('settings'), - ]) -); +const UserActionFieldTypeRt = rt.union([ + rt.literal('comment'), + rt.literal('connector'), + rt.literal('description'), + rt.literal('pushed'), + rt.literal('tags'), + rt.literal('title'), + rt.literal('status'), + rt.literal('settings'), + rt.literal('sub_case'), +]); +const UserActionFieldRt = rt.array(UserActionFieldTypeRt); const UserActionRt = rt.union([ rt.literal('add'), rt.literal('create'), @@ -60,3 +60,4 @@ export type CaseUserActionsResponse = rt.TypeOf; export type UserActionField = rt.TypeOf; +export type UserActionFieldType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/helpers.ts b/x-pack/plugins/case/common/api/helpers.ts index 24c4756a1596b..9c290c0a4d612 100644 --- a/x-pack/plugins/case/common/api/helpers.ts +++ b/x-pack/plugins/case/common/api/helpers.ts @@ -10,6 +10,8 @@ import { CASE_COMMENTS_URL, CASE_USER_ACTIONS_URL, CASE_COMMENT_DETAILS_URL, + SUB_CASE_DETAILS_URL, + SUB_CASES_URL, CASE_PUSH_URL, } from '../constants'; @@ -17,6 +19,14 @@ export const getCaseDetailsUrl = (id: string): string => { return CASE_DETAILS_URL.replace('{case_id}', id); }; +export const getSubCasesUrl = (caseID: string): string => { + return SUB_CASES_URL.replace('{case_id}', caseID); +}; + +export const getSubCaseDetailsUrl = (caseID: string, subCaseID: string): string => { + return SUB_CASE_DETAILS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID); +}; + export const getCaseCommentsUrl = (id: string): string => { return CASE_COMMENTS_URL.replace('{case_id}', id); }; diff --git a/x-pack/plugins/case/common/api/saved_object.ts b/x-pack/plugins/case/common/api/saved_object.ts index 91eace4e3655e..e0ae4ee82c490 100644 --- a/x-pack/plugins/case/common/api/saved_object.ts +++ b/x-pack/plugins/case/common/api/saved_object.ts @@ -20,9 +20,12 @@ export const NumberFromString = new rt.Type( String ); +const ReferenceRt = rt.type({ id: rt.string, type: rt.string }); + export const SavedObjectFindOptionsRt = rt.partial({ defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), - hasReference: rt.type({ id: rt.string, type: rt.string }), + hasReferenceOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + hasReference: rt.union([rt.array(ReferenceRt), ReferenceRt]), fields: rt.array(rt.string), filter: rt.string, page: NumberFromString, diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index 92dd2312f1ecf..5d34ed120ff6f 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -15,6 +15,11 @@ export const CASES_URL = '/api/cases'; export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; + +export const SUB_CASES_PATCH_DEL_URL = `${CASES_URL}/sub_cases`; +export const SUB_CASES_URL = `${CASE_DETAILS_URL}/sub_cases`; +export const SUB_CASE_DETAILS_URL = `${CASE_DETAILS_URL}/sub_cases/{sub_case_id}`; + export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push`; diff --git a/x-pack/plugins/case/package.json b/x-pack/plugins/case/package.json new file mode 100644 index 0000000000000..5a25414296946 --- /dev/null +++ b/x-pack/plugins/case/package.json @@ -0,0 +1,10 @@ +{ + "author": "Elastic", + "name": "case", + "version": "8.0.0", + "private": true, + "license": "Elastic-License", + "scripts": { + "test:sub-cases": "node server/scripts/sub_cases/generator" + } +} diff --git a/x-pack/plugins/case/server/client/alerts/get.ts b/x-pack/plugins/case/server/client/alerts/get.ts index 718dd327aa08c..a7ca5d9742c6b 100644 --- a/x-pack/plugins/case/server/client/alerts/get.ts +++ b/x-pack/plugins/case/server/client/alerts/get.ts @@ -5,24 +5,32 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { CaseClientGetAlerts, CaseClientFactoryArguments } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; +import { AlertServiceContract } from '../../services'; import { CaseClientGetAlertsResponse } from './types'; -export const get = ({ alertsService, request, context }: CaseClientFactoryArguments) => async ({ +interface GetParams { + alertsService: AlertServiceContract; + ids: string[]; + indices: Set; + scopedClusterClient: ElasticsearchClient; +} + +export const get = async ({ + alertsService, ids, -}: CaseClientGetAlerts): Promise => { - const securitySolutionClient = context?.securitySolution?.getAppClient(); - if (securitySolutionClient == null) { - throw Boom.notFound('securitySolutionClient client have not been found'); + indices, + scopedClusterClient, +}: GetParams): Promise => { + if (ids.length === 0 || indices.size <= 0) { + return []; } - if (ids.length === 0) { + const alerts = await alertsService.getAlerts({ ids, indices, scopedClusterClient }); + if (!alerts) { return []; } - const index = securitySolutionClient.getSignalsIndex(); - const alerts = await alertsService.getAlerts({ ids, index, request }); return alerts.hits.hits.map((alert) => ({ id: alert._id, index: alert._index, diff --git a/x-pack/plugins/case/server/client/alerts/update_status.test.ts b/x-pack/plugins/case/server/client/alerts/update_status.test.ts index f7b028fd98cd6..c8df1c8ab74f3 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.test.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.test.ts @@ -10,45 +10,21 @@ import { createMockSavedObjectsRepository } from '../../routes/api/__fixtures__' import { createCaseClientWithMockSavedObjectsClient } from '../mocks'; describe('updateAlertsStatus', () => { - describe('happy path', () => { - test('it update the status of the alert correctly', async () => { - const savedObjectsClient = createMockSavedObjectsRepository(); + it('updates the status of the alert correctly', async () => { + const savedObjectsClient = createMockSavedObjectsRepository(); - const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - await caseClient.client.updateAlertsStatus({ - ids: ['alert-id-1'], - status: CaseStatuses.closed, - }); - - expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ - ids: ['alert-id-1'], - index: '.siem-signals', - request: {}, - status: CaseStatuses.closed, - }); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + await caseClient.client.updateAlertsStatus({ + ids: ['alert-id-1'], + status: CaseStatuses.closed, + indices: new Set(['.siem-signals']), }); - describe('unhappy path', () => { - test('it throws when missing securitySolutionClient', async () => { - expect.assertions(3); - - const savedObjectsClient = createMockSavedObjectsRepository(); - - const caseClient = await createCaseClientWithMockSavedObjectsClient({ - savedObjectsClient, - omitFromContext: ['securitySolution'], - }); - caseClient.client - .updateAlertsStatus({ - ids: ['alert-id-1'], - status: CaseStatuses.closed, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(404); - }); - }); + expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ + scopedClusterClient: expect.anything(), + ids: ['alert-id-1'], + indices: new Set(['.siem-signals']), + status: CaseStatuses.closed, }); }); }); diff --git a/x-pack/plugins/case/server/client/alerts/update_status.ts b/x-pack/plugins/case/server/client/alerts/update_status.ts index daaa6d7233f02..cb18bd4fc16e3 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -5,22 +5,24 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { CaseClientUpdateAlertsStatus, CaseClientFactoryArguments } from '../types'; +import { ElasticsearchClient } from 'src/core/server'; +import { CaseStatuses } from '../../../common/api'; +import { AlertServiceContract } from '../../services'; -export const updateAlertsStatus = ({ +interface UpdateAlertsStatusArgs { + alertsService: AlertServiceContract; + ids: string[]; + status: CaseStatuses; + indices: Set; + scopedClusterClient: ElasticsearchClient; +} + +export const updateAlertsStatus = async ({ alertsService, - request, - context, -}: CaseClientFactoryArguments) => async ({ ids, status, -}: CaseClientUpdateAlertsStatus): Promise => { - const securitySolutionClient = context?.securitySolution?.getAppClient(); - if (securitySolutionClient == null) { - throw Boom.notFound('securitySolutionClient client have not been found'); - } - - const index = securitySolutionClient.getSignalsIndex(); - await alertsService.updateAlertsStatus({ ids, status, index, request }); + indices, + scopedClusterClient, +}: UpdateAlertsStatusArgs): Promise => { + await alertsService.updateAlertsStatus({ ids, status, indices, scopedClusterClient }); }; diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index 919128a2cfbc5..065825472954b 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypes, CasePostRequest, CaseStatuses } from '../../../common/api'; +import { ConnectorTypes, CaseStatuses, CaseType, CaseClientPostRequest } from '../../../common/api'; import { createMockSavedObjectsRepository, @@ -25,10 +25,11 @@ describe('create', () => { describe('happy path', () => { test('it creates the case correctly', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: '123', name: 'Jira', @@ -38,75 +39,100 @@ describe('create', () => { settings: { syncAlerts: true, }, - } as CasePostRequest; + }; const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + 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": "Jira", + "type": ".jira", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions - ).toEqual([ - { - attributes: { - action: 'create', - action_at: '2019-11-25T21:54:48.952Z', - action_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', + // using a snapshot here so we don't have to update the text field manually each time it changes + ).toMatchInlineSnapshot(` + Array [ + Object { + "attributes": Object { + "action": "create", + "action_at": "2019-11-25T21:54:48.952Z", + "action_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "action_field": Array [ + "description", + "status", + "tags", + "title", + "connector", + "settings", + ], + "new_value": "{\\"type\\":\\"individual\\",\\"description\\":\\"This is a brand new case of a bad meanie defacing data\\",\\"title\\":\\"Super Bad Security Issue\\",\\"tags\\":[\\"defacement\\"],\\"connector\\":{\\"id\\":\\"123\\",\\"name\\":\\"Jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"Task\\",\\"priority\\":\\"High\\",\\"parent\\":null}},\\"settings\\":{\\"syncAlerts\\":true}}", + "old_value": null, }, - action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], - new_value: - '{"description":"This is a brand new case of a bad meanie defacing data","title":"Super Bad Security Issue","tags":["defacement"],"connector":{"id":"123","name":"Jira","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}},"settings":{"syncAlerts":true}}', - old_value: null, + "references": Array [ + Object { + "id": "mock-it", + "name": "associated-cases", + "type": "cases", + }, + ], }, - references: [ - { - id: 'mock-it', - name: 'associated-cases', - type: 'cases', - }, - ], - }, - ]); + ] + `); }); test('it creates the case without connector in the configuration', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -122,36 +148,53 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + 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": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('Allow user to create case without authentication', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -170,33 +213,45 @@ describe('create', () => { savedObjectsClient, badAuth: true, }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + 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": null, + "full_name": null, + "username": null, + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); @@ -338,6 +393,7 @@ describe('create', () => { title: 'a title', description: 'This is a brand new case of a bad meanie defacing data', tags: ['defacement'], + type: CaseType.individual, status: CaseStatuses.closed, connector: { id: 'none', @@ -354,7 +410,7 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create({ theCase: postCase }).catch((e) => { + caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(400); @@ -362,10 +418,11 @@ describe('create', () => { }); it(`Returns an error if postNewCase throws`, async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'Throw an error', title: 'Super Bad Security Issue', tags: ['error'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -381,7 +438,7 @@ describe('create', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create({ theCase: postCase }).catch((e) => { + caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(400); diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index b9c2c1991537a..ee47c59072fdd 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -10,14 +10,18 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; +import { SavedObjectsClientContract } from 'src/core/server'; import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; import { - CasePostRequestRt, throwErrors, excess, CaseResponseRt, CaseResponse, + CaseClientPostRequestRt, + CasePostRequest, + CaseType, + User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { @@ -25,22 +29,39 @@ import { transformCaseConnectorToEsConnector, } from '../../routes/api/cases/helpers'; -import { CaseClientCreate, CaseClientFactoryArguments } from '../types'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; + +interface CreateCaseArgs { + caseConfigureService: CaseConfigureServiceSetup; + caseService: CaseServiceSetup; + user: User; + savedObjectsClient: SavedObjectsClientContract; + userActionService: CaseUserActionServiceSetup; + theCase: CasePostRequest; +} -export const create = ({ +export const create = async ({ savedObjectsClient, caseService, caseConfigureService, userActionService, - request, -}: CaseClientFactoryArguments) => async ({ theCase }: CaseClientCreate): Promise => { + user, + theCase, +}: CreateCaseArgs): Promise => { + // default to an individual case if the type is not defined. + const { type = CaseType.individual, ...nonTypeCaseFields } = theCase; const query = pipe( - excess(CasePostRequestRt).decode(theCase), + // decode with the defaulted type field + excess(CaseClientPostRequestRt).decode({ type, ...nonTypeCaseFields }), fold(throwErrors(Boom.badRequest), identity) ); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); + const { username, full_name, email } = user; const createdDate = new Date().toISOString(); const myCaseConfigure = await caseConfigureService.find({ client: savedObjectsClient }); const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); diff --git a/x-pack/plugins/case/server/client/cases/get.ts b/x-pack/plugins/case/server/client/cases/get.ts index c1901ccaae511..eab43a0c4d453 100644 --- a/x-pack/plugins/case/server/client/cases/get.ts +++ b/x-pack/plugins/case/server/client/cases/get.ts @@ -5,17 +5,30 @@ * 2.0. */ +import { SavedObjectsClientContract } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; import { CaseResponseRt, CaseResponse } from '../../../common/api'; -import { CaseClientGet, CaseClientFactoryArguments } from '../types'; +import { CaseServiceSetup } from '../../services'; +import { countAlertsForID } from '../../common'; -export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArguments) => async ({ +interface GetParams { + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + id: string; + includeComments?: boolean; + includeSubCaseComments?: boolean; +} + +export const get = async ({ + savedObjectsClient, + caseService, id, includeComments = false, -}: CaseClientGet): Promise => { + includeSubCaseComments = false, +}: GetParams): Promise => { const theCase = await caseService.getCase({ client: savedObjectsClient, - caseId: id, + id, }); if (!includeComments) { @@ -28,11 +41,12 @@ export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArgume const theComments = await caseService.getAllCaseComments({ client: savedObjectsClient, - caseId: id, + id, options: { sortField: 'created_at', sortOrder: 'asc', }, + includeSubCaseComments, }); return CaseResponseRt.encode( @@ -40,6 +54,7 @@ export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArgume savedObject: theCase, comments: theComments.saved_objects, totalComment: theComments.total, + totalAlerts: countAlertsForID({ comments: theComments, id }), }) ); }; diff --git a/x-pack/plugins/case/server/client/cases/mock.ts b/x-pack/plugins/case/server/client/cases/mock.ts index 57e2d4373a52b..2be9f41059831 100644 --- a/x-pack/plugins/case/server/client/cases/mock.ts +++ b/x-pack/plugins/case/server/client/cases/mock.ts @@ -10,6 +10,7 @@ import { CommentType, ConnectorMappingsAttributes, CaseUserActionsResponse, + AssociationType, } from '../../../common/api'; import { BasicParams } from './types'; @@ -27,6 +28,7 @@ const entity = { }; export const comment: CommentResponse = { + associationType: AssociationType.case, id: 'mock-comment-1', comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user as const, @@ -48,6 +50,7 @@ export const comment: CommentResponse = { }; export const commentAlert: CommentResponse = { + associationType: AssociationType.case, id: 'mock-comment-1', alertId: 'alert-id-1', index: 'alert-index-1', diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts index f329fb4d00d07..1e0c246855d88 100644 --- a/x-pack/plugins/case/server/client/cases/push.ts +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -6,9 +6,13 @@ */ import Boom, { isBoom, Boom as BoomType } from '@hapi/boom'; - -import { SavedObjectsBulkUpdateResponse, SavedObjectsUpdateResponse } from 'kibana/server'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { + SavedObjectsBulkUpdateResponse, + SavedObjectsClientContract, + SavedObjectsUpdateResponse, +} from 'kibana/server'; +import { ActionResult, ActionsClient } from '../../../../actions/server'; +import { flattenCaseSavedObject, getAlertIndicesAndIDs } from '../../routes/api/utils'; import { ActionConnector, @@ -18,11 +22,18 @@ import { ExternalServiceResponse, ESCaseAttributes, CommentAttributes, + CaseUserActionsResponse, + User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; -import { CaseClientPush, CaseClientFactoryArguments } from '../types'; -import { createIncident, getCommentContextFromAttributes, isCommentAlertType } from './utils'; +import { createIncident, getCommentContextFromAttributes } from './utils'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; +import { CaseClientHandler } from '../client'; const createError = (e: Error | BoomType, message: string): Error | BoomType => { if (isBoom(e)) { @@ -34,30 +45,40 @@ const createError = (e: Error | BoomType, message: string): Error | BoomType => return Error(message); }; -export const push = ({ +interface PushParams { + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + caseConfigureService: CaseConfigureServiceSetup; + userActionService: CaseUserActionServiceSetup; + user: User; + caseId: string; + connectorId: string; + caseClient: CaseClientHandler; + actionsClient: ActionsClient; +} + +export const push = async ({ savedObjectsClient, caseService, caseConfigureService, userActionService, - request, - response, -}: CaseClientFactoryArguments) => async ({ - actionsClient, caseClient, - caseId, + actionsClient, connectorId, -}: CaseClientPush): Promise => { + caseId, + user, +}: PushParams): Promise => { /* Start of push to external service */ - let theCase; - let connector; - let userActions; + let theCase: CaseResponse; + let connector: ActionResult; + let userActions: CaseUserActionsResponse; let alerts; let connectorMappings; let externalServiceIncident; try { [theCase, connector, userActions] = await Promise.all([ - caseClient.get({ id: caseId, includeComments: true }), + caseClient.get({ id: caseId, includeComments: true, includeSubCaseComments: true }), actionsClient.get({ id: connectorId }), caseClient.getUserActions({ caseId }), ]); @@ -73,9 +94,12 @@ export const push = ({ ); } + const { ids, indices } = getAlertIndicesAndIDs(theCase?.comments); + try { alerts = await caseClient.getAlerts({ - ids: theCase?.comments?.filter(isCommentAlertType).map((comment) => comment.alertId) ?? [], + ids, + indices, }); } catch (e) { throw new Error(`Error getting alerts for case with id ${theCase.id}: ${e.message}`); @@ -84,7 +108,6 @@ export const push = ({ try { connectorMappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.actionTypeId, }); @@ -124,27 +147,26 @@ export const push = ({ /* End of push to external service */ /* Start of update case with push information */ - let user; let myCase; let myCaseConfigure; let comments; try { - [user, myCase, myCaseConfigure, comments] = await Promise.all([ - caseService.getUser({ request, response }), + [myCase, myCaseConfigure, comments] = await Promise.all([ caseService.getCase({ client: savedObjectsClient, - caseId, + id: caseId, }), caseConfigureService.find({ client: savedObjectsClient }), caseService.getAllCaseComments({ client: savedObjectsClient, - caseId, + id: caseId, options: { fields: [], page: 1, perPage: theCase?.totalComment ?? 0, }, + includeSubCaseComments: true, }), ]); } catch (e) { diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index 0fd72c86a50ba..53e233c74deb4 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -40,39 +40,55 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.closed, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "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", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions @@ -123,39 +139,51 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.open, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + 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", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); test('it change the status of case to in-progress correctly', async () => { @@ -174,43 +202,55 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: { - issueType: 'Task', - parent: null, - priority: 'High', + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + 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-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "in-progress", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - id: 'mock-id-4', - external_service: null, - status: CaseStatuses['in-progress'], - tags: ['LOLBins'], - title: 'Another bad one', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + ] + `); }); test('it updates a case without a connector.id', async () => { @@ -229,39 +269,54 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - id: 'mock-no-connector_id', - comments: [], - totalComment: 0, - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'elastic', email: 'testemail@elastic.co', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.closed, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "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": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); test('it updates the connector correctly', async () => { @@ -285,47 +340,55 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - id: 'mock-id-3', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { - id: '456', - name: 'My connector 2', - type: ConnectorTypes.jira, - fields: { issueType: 'Bug', priority: 'Low', parent: null }, - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - external_service: null, - title: 'Another bad one', - status: CaseStatuses.open, - tags: ['LOLBins'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'Awesome D00d', - email: 'd00d@awesome.com', - username: 'awesome', - }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Bug", + "parent": null, + "priority": "Low", + }, + "id": "456", + "name": "My connector 2", + "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", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); test('it updates alert status when the status is updated and syncAlerts=true', async () => { @@ -341,20 +404,29 @@ describe('update', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, - caseCommentSavedObject: [{ ...mockCaseComments[3] }], + caseCommentSavedObject: [ + { + ...mockCaseComments[3], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-1', + }, + ], + }, + ], }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-id'], status: 'closed', + indices: new Set(['test-index']), }); }); @@ -382,10 +454,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -414,14 +483,12 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-id'], status: 'open', + indices: new Set(['test-index']), }); }); @@ -444,10 +511,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -478,25 +542,50 @@ describe('update', () => { ...mockCases[1], }, ], - caseCommentSavedObject: [{ ...mockCaseComments[3] }, { ...mockCaseComments[4] }], + caseCommentSavedObject: [ + { + ...mockCaseComments[3], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-1', + }, + ], + }, + { + ...mockCaseComments[4], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-2', + }, + ], + }, + ], }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - + await caseClient.client.update(patchCases); + /** + * the update code will put each comment into a status bucket and then make at most 1 call + * to ES for each status bucket + * Now instead of doing a call per case to get the comments, it will do a single call with all the cases + * and sub cases and get all the comments in one go + */ expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(1, { - ids: ['test-id', 'test-id-2'], + ids: ['test-id'], status: 'open', + indices: new Set(['test-index']), }); expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(2, { - ids: ['test-id', 'test-id-2'], + ids: ['test-id-2'], status: 'closed', + indices: new Set(['test-index-2']), }); }); @@ -518,10 +607,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -607,7 +693,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(406); @@ -637,7 +723,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(404); @@ -664,7 +750,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(409); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 99802ba47c839..a4ca2b4cbdef9 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -10,18 +10,34 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObjectsFindResponse } from 'kibana/server'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsFindResult, +} from 'kibana/server'; +import { + AlertInfo, + flattenCaseSavedObject, + isCommentRequestTypeAlertOrGenAlert, +} from '../../routes/api/utils'; import { throwErrors, excess, CasesResponseRt, - CasesPatchRequestRt, ESCasePatchRequest, CasePatchRequest, CasesResponse, CaseStatuses, + CasesPatchRequestRt, + CommentType, + ESCaseAttributes, + CaseType, + CasesPatchRequest, + AssociationType, + CommentAttributes, + User, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { @@ -29,17 +45,296 @@ import { transformCaseConnectorToEsConnector, } from '../../routes/api/cases/helpers'; -import { CaseClientUpdate, CaseClientFactoryArguments } from '../types'; +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; +import { + CASE_COMMENT_SAVED_OBJECT, + CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; +import { CaseClientHandler } from '..'; +import { addAlertInfoToStatusMap } from '../../common'; + +/** + * Throws an error if any of the requests attempt to update a collection style cases' status field. + */ +function throwIfUpdateStatusOfCollection( + requests: ESCasePatchRequest[], + casesMap: Map> +) { + const requestsUpdatingStatusOfCollection = requests.filter( + (req) => + req.status !== undefined && casesMap.get(req.id)?.attributes.type === CaseType.collection + ); + + if (requestsUpdatingStatusOfCollection.length > 0) { + const ids = requestsUpdatingStatusOfCollection.map((req) => req.id); + throw Boom.badRequest( + `Updating the status of a collection is not allowed ids: [${ids.join(', ')}]` + ); + } +} + +/** + * Throws an error if any of the requests attempt to update a collection style case to an individual one. + */ +function throwIfUpdateTypeCollectionToIndividual( + requests: ESCasePatchRequest[], + casesMap: Map> +) { + const requestsUpdatingTypeCollectionToInd = requests.filter( + (req) => + req.type === CaseType.individual && + casesMap.get(req.id)?.attributes.type === CaseType.collection + ); + + if (requestsUpdatingTypeCollectionToInd.length > 0) { + const ids = requestsUpdatingTypeCollectionToInd.map((req) => req.id); + throw Boom.badRequest( + `Converting a collection to an individual case is not allowed ids: [${ids.join(', ')}]` + ); + } +} + +/** + * Throws an error if any of the requests attempt to update an individual style cases' type field to a collection + * when alerts are attached to the case. + */ +async function throwIfInvalidUpdateOfTypeWithAlerts({ + requests, + caseService, + client, +}: { + requests: ESCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}) { + const getAlertsForID = async (caseToUpdate: ESCasePatchRequest) => { + const alerts = await caseService.getAllCaseComments({ + client, + id: caseToUpdate.id, + options: { + fields: [], + // there should never be generated alerts attached to an individual case but we'll check anyway + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + page: 1, + perPage: 1, + }, + }); + + return { id: caseToUpdate.id, alerts }; + }; + + const requestsUpdatingTypeField = requests.filter((req) => req.type === CaseType.collection); + const casesAlertTotals = await Promise.all( + requestsUpdatingTypeField.map((caseToUpdate) => getAlertsForID(caseToUpdate)) + ); + + // grab the cases that have at least one alert comment attached to them + const typeUpdateWithAlerts = casesAlertTotals.filter((caseInfo) => caseInfo.alerts.total > 0); + + if (typeUpdateWithAlerts.length > 0) { + const ids = typeUpdateWithAlerts.map((req) => req.id); + throw Boom.badRequest( + `Converting a case to a collection is not allowed when it has alert comments, ids: [${ids.join( + ', ' + )}]` + ); + } +} + +/** + * Get the id from a reference in a comment for a specific type. + */ +function getID( + comment: SavedObject, + type: typeof CASE_SAVED_OBJECT | typeof SUB_CASE_SAVED_OBJECT +): string | undefined { + return comment.references.find((ref) => ref.type === type)?.id; +} + +/** + * Gets all the alert comments (generated or user alerts) for the requested cases. + */ +async function getAlertComments({ + casesToSync, + caseService, + client, +}: { + casesToSync: ESCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const idsOfCasesToSync = casesToSync.map((casePatchReq) => casePatchReq.id); + + // getAllCaseComments will by default get all the comments, unless page or perPage fields are set + return caseService.getAllCaseComments({ + client, + id: idsOfCasesToSync, + includeSubCaseComments: true, + options: { + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + }, + }); +} + +/** + * Returns a map of sub case IDs to their status. This uses a group of alert comments to determine which sub cases should + * be retrieved. This is based on whether the comment is associated to a sub case. + */ +async function getSubCasesToStatus({ + totalAlerts, + caseService, + client, +}: { + totalAlerts: SavedObjectsFindResponse; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const subCasesToRetrieve = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if ( + isCommentRequestTypeAlertOrGenAlert(alertComment.attributes) && + alertComment.attributes.associationType === AssociationType.subCase + ) { + const id = getID(alertComment, SUB_CASE_SAVED_OBJECT); + if (id !== undefined) { + acc.add(id); + } + } + return acc; + }, new Set()); + + const subCases = await caseService.getSubCases({ + ids: Array.from(subCasesToRetrieve.values()), + client, + }); + + return subCases.saved_objects.reduce((acc, subCase) => { + // log about the sub cases that we couldn't find + if (!subCase.error) { + acc.set(subCase.id, subCase.attributes.status); + } + return acc; + }, new Map()); +} + +/** + * Returns what status the alert comment should have based on whether it is associated to a case or sub case. + */ +function getSyncStatusForComment({ + alertComment, + casesToSyncToStatus, + subCasesToStatus, +}: { + alertComment: SavedObjectsFindResult; + casesToSyncToStatus: Map; + subCasesToStatus: Map; +}): CaseStatuses { + let status: CaseStatuses = CaseStatuses.open; + if (alertComment.attributes.associationType === AssociationType.case) { + const id = getID(alertComment, CASE_SAVED_OBJECT); + // We should log if we can't find the status + // attempt to get the case status from our cases to sync map if we found the ID otherwise default to open + status = + id !== undefined ? casesToSyncToStatus.get(id) ?? CaseStatuses.open : CaseStatuses.open; + } else if (alertComment.attributes.associationType === AssociationType.subCase) { + const id = getID(alertComment, SUB_CASE_SAVED_OBJECT); + status = id !== undefined ? subCasesToStatus.get(id) ?? CaseStatuses.open : CaseStatuses.open; + } + return status; +} + +/** + * Updates the alert ID's status field based on the patch requests + */ +async function updateAlerts({ + casesWithSyncSettingChangedToOn, + casesWithStatusChangedAndSynced, + casesMap, + caseService, + client, + caseClient, +}: { + casesWithSyncSettingChangedToOn: ESCasePatchRequest[]; + casesWithStatusChangedAndSynced: ESCasePatchRequest[]; + casesMap: Map>; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseClient: CaseClientHandler; +}) { + /** + * It's possible that a case ID can appear multiple times in each array. I'm intentionally placing the status changes + * last so when the map is built we will use the last status change as the source of truth. + */ + const casesToSync = [...casesWithSyncSettingChangedToOn, ...casesWithStatusChangedAndSynced]; + + // build a map of case id to the status it has + // this will have collections in it but the alerts should be associated to sub cases and not collections so it shouldn't + // matter. + const casesToSyncToStatus = casesToSync.reduce((acc, caseInfo) => { + acc.set( + caseInfo.id, + caseInfo.status ?? casesMap.get(caseInfo.id)?.attributes.status ?? CaseStatuses.open + ); + return acc; + }, new Map()); + + // get all the alerts for all the alert comments for all cases and collections. Collections themselves won't have any + // but their sub cases could + const totalAlerts = await getAlertComments({ + casesToSync, + caseService, + client, + }); + + // get a map of sub case id to the sub case status + const subCasesToStatus = await getSubCasesToStatus({ totalAlerts, client, caseService }); + + // create a map of the case statuses to the alert information that we need to update for that status + // This allows us to make at most 3 calls to ES, one for each status type that we need to update + // One potential improvement here is to do a tick (set timeout) to reduce the memory footprint if that becomes an issue + const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { + const status = getSyncStatusForComment({ + alertComment, + casesToSyncToStatus, + subCasesToStatus, + }); + + addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); + } -export const update = ({ + return acc; + }, new Map()); + + // This does at most 3 calls to Elasticsearch to update the status of the alerts to either open, closed, or in-progress + for (const [status, alertInfo] of alertsToUpdate.entries()) { + if (alertInfo.ids.length > 0 && alertInfo.indices.size > 0) { + caseClient.updateAlertsStatus({ + ids: alertInfo.ids, + status, + indices: alertInfo.indices, + }); + } + } +} + +interface UpdateArgs { + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + user: User; + caseClient: CaseClientHandler; + cases: CasesPatchRequest; +} + +export const update = async ({ savedObjectsClient, caseService, userActionService, - request, -}: CaseClientFactoryArguments) => async ({ + user, caseClient, cases, -}: CaseClientUpdate): Promise => { +}: UpdateArgs): Promise => { const query = pipe( excess(CasesPatchRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) @@ -95,133 +390,119 @@ export const update = ({ return Object.keys(updateCaseAttributes).length > 0; }); - if (updateFilterCases.length > 0) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const updatedDt = new Date().toISOString(); - const updatedCases = await caseService.patchCases({ - client: savedObjectsClient, - cases: updateFilterCases.map((thisCase) => { - const { id: caseId, version, ...updateCaseAttributes } = thisCase; - let closedInfo = {}; - if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { - closedInfo = { - closed_at: updatedDt, - closed_by: { email, full_name, username }, - }; - } else if ( - updateCaseAttributes.status && - (updateCaseAttributes.status === CaseStatuses.open || - updateCaseAttributes.status === CaseStatuses['in-progress']) - ) { - closedInfo = { - closed_at: null, - closed_by: null, - }; - } - return { - caseId, - updatedAttributes: { - ...updateCaseAttributes, - ...closedInfo, - updated_at: updatedDt, - updated_by: { email, full_name, username }, - }, - version, - }; - }), - }); + if (updateFilterCases.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } - // If a status update occurred and the case is synced then we need to update all alerts' status - // attached to the case to the new status. - const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); - return ( - currentCase != null && - caseToUpdate.status != null && - currentCase.attributes.status !== caseToUpdate.status && - currentCase.attributes.settings.syncAlerts - ); - }); + const casesMap = myCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); - // If syncAlerts setting turned on we need to update all alerts' status - // attached to the case to the current status. - const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); - return ( - currentCase != null && - caseToUpdate.settings?.syncAlerts != null && - currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && - caseToUpdate.settings.syncAlerts - ); - }); + throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); + throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); + await throwIfInvalidUpdateOfTypeWithAlerts({ + requests: updateFilterCases, + caseService, + client: savedObjectsClient, + }); - for (const theCase of [ - ...casesWithSyncSettingChangedToOn, - ...casesWithStatusChangedAndSynced, - ]) { - const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); - const totalComments = await caseService.getAllCaseComments({ - client: savedObjectsClient, - caseId: theCase.id, - options: { - fields: [], - filter: 'cases-comments.attributes.type: alert', - page: 1, - perPage: 1, + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const updatedDt = new Date().toISOString(); + const updatedCases = await caseService.patchCases({ + client: savedObjectsClient, + cases: updateFilterCases.map((thisCase) => { + const { id: caseId, version, ...updateCaseAttributes } = thisCase; + let closedInfo = {}; + if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { + closedInfo = { + closed_at: updatedDt, + closed_by: { email, full_name, username }, + }; + } else if ( + updateCaseAttributes.status && + (updateCaseAttributes.status === CaseStatuses.open || + updateCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + caseId, + updatedAttributes: { + ...updateCaseAttributes, + ...closedInfo, + updated_at: updatedDt, + updated_by: { email, full_name, username }, }, - }); + version, + }; + }), + }); - const caseComments = (await caseService.getAllCaseComments({ - client: savedObjectsClient, - caseId: theCase.id, - options: { - fields: [], - filter: 'cases-comments.attributes.type: alert', - page: 1, - perPage: totalComments.total, - }, - // The filter guarantees that the comments will be of type alert - })) as SavedObjectsFindResponse<{ alertId: string }>; - - const commentIds = caseComments.saved_objects.map(({ attributes: { alertId } }) => alertId); - if (commentIds.length > 0) { - caseClient.updateAlertsStatus({ - ids: commentIds, - // Either there is a status update or the syncAlerts got turned on. - status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, - }); - } - } + // If a status update occurred and the case is synced then we need to update all alerts' status + // attached to the case to the new status. + const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.status != null && + currentCase.attributes.status !== caseToUpdate.status && + currentCase.attributes.settings.syncAlerts + ); + }); - const returnUpdatedCase = myCases.saved_objects - .filter((myCase) => - updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) - ) - .map((myCase) => { - const updatedCase = updatedCases.saved_objects.find((c) => c.id === myCase.id); - return flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - version: updatedCase?.version ?? myCase.version, - }, - }); - }); + // If syncAlerts setting turned on we need to update all alerts' status + // attached to the case to the current status. + const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.settings?.syncAlerts != null && + currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && + caseToUpdate.settings.syncAlerts + ); + }); + + // Update the alert's status to match any case status or sync settings changes + await updateAlerts({ + casesWithStatusChangedAndSynced, + casesWithSyncSettingChangedToOn, + caseService, + client: savedObjectsClient, + caseClient, + casesMap, + }); - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: buildCaseUserActions({ - originalCases: myCases.saved_objects, - updatedCases: updatedCases.saved_objects, - actionDate: updatedDt, - actionBy: { email, full_name, username }, - }), + const returnUpdatedCase = myCases.saved_objects + .filter((myCase) => + updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) + ) + .map((myCase) => { + const updatedCase = updatedCases.saved_objects.find((c) => c.id === myCase.id); + return flattenCaseSavedObject({ + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + version: updatedCase?.version ?? myCase.version, + }, + }); }); - return CasesResponseRt.encode(returnUpdatedCase); - } - throw Boom.notAcceptable('All update fields are identical to current version.'); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: buildCaseUserActions({ + originalCases: myCases.saved_objects, + updatedCases: updatedCases.saved_objects, + actionDate: updatedDt, + actionBy: { email, full_name, username }, + }), + }); + + return CasesResponseRt.encode(returnUpdatedCase); }; diff --git a/x-pack/plugins/case/server/client/cases/utils.test.ts b/x-pack/plugins/case/server/client/cases/utils.test.ts index dca2c34602678..361d0fb561afd 100644 --- a/x-pack/plugins/case/server/client/cases/utils.test.ts +++ b/x-pack/plugins/case/server/client/cases/utils.test.ts @@ -537,12 +537,12 @@ describe('utils', () => { }, { comment: - 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', commentId: 'comment-alert-1', }, { comment: - 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', commentId: 'comment-alert-2', }, ]); diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index 6974fd4ffa288..78bdc6d282c69 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -38,6 +38,7 @@ import { TransformerArgs, TransformFieldsArgs, } from './types'; +import { getAlertIds } from '../../routes/api/utils'; export const getLatestPushInfo = ( connectorId: string, @@ -66,8 +67,9 @@ const isConnectorSupported = (connectorId: string): connectorId is FormatterConn const getCommentContent = (comment: CommentResponse): string => { if (comment.type === CommentType.user) { return comment.comment; - } else if (comment.type === CommentType.alert) { - return `Alert with id ${comment.alertId} added to case`; + } else if (comment.type === CommentType.alert || comment.type === CommentType.generatedAlert) { + const ids = getAlertIds(comment); + return `Alert with ids ${ids.join(', ')} added to case`; } return ''; @@ -306,9 +308,10 @@ export const getCommentContextFromAttributes = ( type: CommentType.user, comment: attributes.comment, }; + case CommentType.generatedAlert: case CommentType.alert: return { - type: CommentType.alert, + type: attributes.type, alertId: attributes.alertId, index: attributes.index, }; diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts new file mode 100644 index 0000000000000..c684548decbe6 --- /dev/null +++ b/x-pack/plugins/case/server/client/client.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 { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { + CaseClientFactoryArguments, + CaseClient, + ConfigureFields, + MappingsClient, + CaseClientUpdateAlertsStatus, + CaseClientAddComment, + CaseClientGet, + CaseClientGetUserActions, + CaseClientGetAlerts, + CaseClientPush, +} from './types'; +import { create } from './cases/create'; +import { update } from './cases/update'; +import { addComment } from './comments/add'; +import { getFields } from './configure/get_fields'; +import { getMappings } from './configure/get_mappings'; +import { updateAlertsStatus } from './alerts/update_status'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + ConnectorMappingsServiceSetup, + CaseUserActionServiceSetup, + AlertServiceContract, +} from '../services'; +import { CasesPatchRequest, CasePostRequest, User } from '../../common/api'; +import { get } from './cases/get'; +import { get as getUserActions } from './user_actions/get'; +import { get as getAlerts } from './alerts/get'; +import { push } from './cases/push'; + +/** + * This class is a pass through for common case functionality (like creating, get a case). + */ +export class CaseClientHandler implements CaseClient { + private readonly _scopedClusterClient: ElasticsearchClient; + private readonly _caseConfigureService: CaseConfigureServiceSetup; + private readonly _caseService: CaseServiceSetup; + private readonly _connectorMappingsService: ConnectorMappingsServiceSetup; + private readonly user: User; + private readonly _savedObjectsClient: SavedObjectsClientContract; + private readonly _userActionService: CaseUserActionServiceSetup; + private readonly _alertsService: AlertServiceContract; + + constructor(clientArgs: CaseClientFactoryArguments) { + this._scopedClusterClient = clientArgs.scopedClusterClient; + this._caseConfigureService = clientArgs.caseConfigureService; + this._caseService = clientArgs.caseService; + this._connectorMappingsService = clientArgs.connectorMappingsService; + this.user = clientArgs.user; + this._savedObjectsClient = clientArgs.savedObjectsClient; + this._userActionService = clientArgs.userActionService; + this._alertsService = clientArgs.alertsService; + } + + public async create(caseInfo: CasePostRequest) { + return create({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + caseConfigureService: this._caseConfigureService, + userActionService: this._userActionService, + user: this.user, + theCase: caseInfo, + }); + } + + public async update(cases: CasesPatchRequest) { + return update({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + user: this.user, + cases, + caseClient: this, + }); + } + + public async addComment({ caseId, comment }: CaseClientAddComment) { + return addComment({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + caseClient: this, + caseId, + comment, + user: this.user, + }); + } + + public async getFields(fields: ConfigureFields) { + return getFields(fields); + } + + public async getMappings(args: MappingsClient) { + return getMappings({ + ...args, + savedObjectsClient: this._savedObjectsClient, + connectorMappingsService: this._connectorMappingsService, + caseClient: this, + }); + } + + public async updateAlertsStatus(args: CaseClientUpdateAlertsStatus) { + return updateAlertsStatus({ + ...args, + alertsService: this._alertsService, + scopedClusterClient: this._scopedClusterClient, + }); + } + + public async get(args: CaseClientGet) { + return get({ + ...args, + caseService: this._caseService, + savedObjectsClient: this._savedObjectsClient, + }); + } + + public async getUserActions(args: CaseClientGetUserActions) { + return getUserActions({ + ...args, + savedObjectsClient: this._savedObjectsClient, + userActionService: this._userActionService, + }); + } + + public async getAlerts(args: CaseClientGetAlerts) { + return getAlerts({ + ...args, + alertsService: this._alertsService, + scopedClusterClient: this._scopedClusterClient, + }); + } + + public async push(args: CaseClientPush) { + return push({ + ...args, + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + user: this.user, + caseClient: this, + caseConfigureService: this._caseConfigureService, + }); + } +} diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index 746176284a292..315203a1f5e1d 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -32,7 +32,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -42,22 +41,25 @@ describe('addComment', () => { expect(res.id).toEqual('mock-id-1'); expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it adds a comment of type alert correctly', async () => { @@ -68,7 +70,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -79,23 +80,26 @@ describe('addComment', () => { expect(res.id).toEqual('mock-id-1'); expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toEqual({ - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "alertId": "test-id", + "associationType": "case", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "id": "mock-comment", + "index": "test-index", + "pushed_at": null, + "pushed_by": null, + "type": "alert", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it updates the case correctly after adding a comment', async () => { @@ -106,7 +110,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -130,7 +133,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -181,7 +183,6 @@ describe('addComment', () => { badAuth: true, }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -190,22 +191,25 @@ describe('addComment', () => { }); expect(res.id).toEqual('mock-id-1'); - expect(res.comments![res.comments!.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it update the status of the alert if the case is synced with alerts', async () => { @@ -222,7 +226,6 @@ describe('addComment', () => { caseClient.client.updateAlertsStatus = jest.fn(); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -234,6 +237,7 @@ describe('addComment', () => { expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-alert'], status: 'open', + indices: new Set(['test-index']), }); }); @@ -256,7 +260,6 @@ describe('addComment', () => { caseClient.client.updateAlertsStatus = jest.fn(); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -336,7 +339,6 @@ describe('addComment', () => { ['alertId', 'index'].forEach((attribute) => { caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -398,7 +400,6 @@ describe('addComment', () => { ['comment'].forEach((attribute) => { caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -425,7 +426,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'not-exists', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -449,7 +449,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Throw an error', @@ -474,7 +473,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-4', comment: { type: CommentType.alert, diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 58d7c9abcbfd3..7dd1b4a8f6c5c 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -10,135 +10,297 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { decodeComment, flattenCaseSavedObject, transformNewComment } from '../../routes/api/utils'; +import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; +import { + decodeCommentRequest, + getAlertIds, + isCommentRequestTypeGenAlert, +} from '../../routes/api/utils'; import { throwErrors, - CaseResponseRt, CommentRequestRt, - CaseResponse, CommentType, CaseStatuses, + CaseType, + SubCaseAttributes, + CommentRequest, + CollectionWithSubCaseResponse, + User, + CommentRequestAlertType, + AlertCommentRequestRt, } from '../../../common/api'; -import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; +import { + buildCaseUserActionItem, + buildCommentUserActionItem, +} from '../../services/user_actions/helpers'; + +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; +import { CommentableCase } from '../../common'; +import { CaseClientHandler } from '..'; + +async function getSubCase({ + caseService, + savedObjectsClient, + caseId, + createdAt, + userActionService, + user, +}: { + caseService: CaseServiceSetup; + savedObjectsClient: SavedObjectsClientContract; + caseId: string; + createdAt: string; + userActionService: CaseUserActionServiceSetup; + user: User; +}): Promise> { + const mostRecentSubCase = await caseService.getMostRecentSubCase(savedObjectsClient, caseId); + if (mostRecentSubCase && mostRecentSubCase.attributes.status !== CaseStatuses.closed) { + return mostRecentSubCase; + } -import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; -import { CASE_SAVED_OBJECT } from '../../saved_object_types'; + const newSubCase = await caseService.createSubCase({ + client: savedObjectsClient, + createdAt, + caseId, + createdBy: user, + }); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCaseUserActionItem({ + action: 'create', + actionAt: createdAt, + actionBy: user, + caseId, + subCaseId: newSubCase.id, + fields: ['status', 'sub_case'], + newValue: JSON.stringify({ status: newSubCase.attributes.status }), + }), + ], + }); + return newSubCase; +} + +interface AddCommentFromRuleArgs { + caseClient: CaseClientHandler; + caseId: string; + comment: CommentRequestAlertType; + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; +} -export const addComment = ({ +const addGeneratedAlerts = async ({ savedObjectsClient, caseService, userActionService, - request, -}: CaseClientFactoryArguments) => async ({ caseClient, caseId, comment, -}: CaseClientAddComment): Promise => { +}: AddCommentFromRuleArgs): Promise => { const query = pipe( - CommentRequestRt.decode(comment), + AlertCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); - decodeComment(comment); + decodeCommentRequest(comment); + + // This function only supports adding generated alerts + if (comment.type !== CommentType.generatedAlert) { + throw Boom.internal('Attempting to add a non generated alert in the wrong context'); + } + const createdDate = new Date().toISOString(); - const myCase = await caseService.getCase({ + const caseInfo = await caseService.getCase({ client: savedObjectsClient, - caseId, + id: caseId, }); - // An alert cannot be attach to a closed case. - if (query.type === CommentType.alert && myCase.attributes.status === CaseStatuses.closed) { - throw Boom.badRequest('Alert cannot be attached to a closed case'); + if ( + query.type === CommentType.generatedAlert && + caseInfo.attributes.type !== CaseType.collection + ) { + throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); } - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const createdDate = new Date().toISOString(); + const userDetails: User = { + username: caseInfo.attributes.created_by?.username, + full_name: caseInfo.attributes.created_by?.full_name, + email: caseInfo.attributes.created_by?.email, + }; - const [newComment, updatedCase] = await Promise.all([ - caseService.postNewComment({ - client: savedObjectsClient, - attributes: transformNewComment({ - createdDate, - ...query, - username, - full_name, - email, - }), - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: myCase.id, - }, - ], - }), - caseService.patchCase({ - client: savedObjectsClient, - caseId, - updatedAttributes: { - updated_at: createdDate, - updated_by: { username, full_name, email }, - }, - version: myCase.version, - }), - ]); + const subCase = await getSubCase({ + caseService, + savedObjectsClient, + caseId, + createdAt: createdDate, + userActionService, + user: userDetails, + }); + + const commentableCase = new CommentableCase({ + collection: caseInfo, + subCase, + soClient: savedObjectsClient, + service: caseService, + }); - // If the case is synced with alerts the newly attached alert must match the status of the case. - if (newComment.attributes.type === CommentType.alert && myCase.attributes.settings.syncAlerts) { + const { + comment: newComment, + commentableCase: updatedCase, + } = await commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }); + + if ( + (newComment.attributes.type === CommentType.alert || + newComment.attributes.type === CommentType.generatedAlert) && + caseInfo.attributes.settings.syncAlerts + ) { + const ids = getAlertIds(query); await caseClient.updateAlertsStatus({ - ids: [newComment.attributes.alertId], - status: myCase.attributes.status, + ids, + status: subCase.attributes.status, + indices: new Set([newComment.attributes.index]), }); } - const totalCommentsFindByCases = await caseService.getAllCaseComments({ + await userActionService.postUserActions({ client: savedObjectsClient, - caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { ...userDetails }, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], }); - const [comments] = await Promise.all([ - caseService.getAllCaseComments({ - client: savedObjectsClient, - caseId, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindByCases.total, - }, + return updatedCase.encode(); +}; + +async function getCombinedCase( + service: CaseServiceSetup, + client: SavedObjectsClientContract, + id: string +): Promise { + const [casePromise, subCasePromise] = await Promise.allSettled([ + service.getCase({ + client, + id, }), - userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCommentUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { username, full_name, email }, - caseId: myCase.id, - commentId: newComment.id, - fields: ['comment'], - newValue: JSON.stringify(query), - }), - ], + service.getSubCase({ + client, + id, }), ]); - return CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase.attributes }, - version: updatedCase.version ?? myCase.version, - references: myCase.references, - }, - comments: comments.saved_objects, - }) + if (subCasePromise.status === 'fulfilled') { + if (subCasePromise.value.references.length > 0) { + const caseValue = await service.getCase({ + client, + id: subCasePromise.value.references[0].id, + }); + return new CommentableCase({ + collection: caseValue, + subCase: subCasePromise.value, + service, + soClient: client, + }); + } else { + throw Boom.badRequest('Sub case found without reference to collection'); + } + } + + if (casePromise.status === 'rejected') { + throw casePromise.reason; + } else { + return new CommentableCase({ collection: casePromise.value, service, soClient: client }); + } +} + +interface AddCommentArgs { + caseClient: CaseClientHandler; + caseId: string; + comment: CommentRequest; + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + user: User; +} + +export const addComment = async ({ + savedObjectsClient, + caseService, + userActionService, + caseClient, + caseId, + comment, + user, +}: AddCommentArgs): Promise => { + const query = pipe( + CommentRequestRt.decode(comment), + fold(throwErrors(Boom.badRequest), identity) ); + + if (isCommentRequestTypeGenAlert(comment)) { + return addGeneratedAlerts({ + caseId, + comment, + caseClient, + savedObjectsClient, + userActionService, + caseService, + }); + } + + decodeCommentRequest(comment); + const createdDate = new Date().toISOString(); + + const combinedCase = await getCombinedCase(caseService, savedObjectsClient, caseId); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const userInfo: User = { + username, + full_name, + email, + }; + + const { comment: newComment, commentableCase: updatedCase } = await combinedCase.createComment({ + createdDate, + user: userInfo, + commentReq: query, + }); + + if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { + const ids = getAlertIds(query); + await caseClient.updateAlertsStatus({ + ids, + status: updatedCase.status, + indices: new Set([newComment.attributes.index]), + }); + } + + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], + }); + + return updatedCase.encode(); }; diff --git a/x-pack/plugins/case/server/client/configure/get_fields.ts b/x-pack/plugins/case/server/client/configure/get_fields.ts index a797e120b971b..deabae33810b2 100644 --- a/x-pack/plugins/case/server/client/configure/get_fields.ts +++ b/x-pack/plugins/case/server/client/configure/get_fields.ts @@ -11,7 +11,7 @@ import { GetFieldsResponse } from '../../../common/api'; import { ConfigureFields } from '../types'; import { createDefaultMapping, formatFields } from './utils'; -export const getFields = () => async ({ +export const getFields = async ({ actionsClient, connectorType, connectorId, diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.test.ts b/x-pack/plugins/case/server/client/configure/get_mappings.test.ts index 4ec9fa7e8e8c2..d4dad182d815e 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.test.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.test.ts @@ -31,7 +31,6 @@ describe('get_mappings', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getMappings({ actionsClient: actionsMock, - caseClient: caseClient.client, connectorType: ConnectorTypes.jira, connectorId: '123', }); @@ -45,7 +44,6 @@ describe('get_mappings', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getMappings({ actionsClient: actionsMock, - caseClient: caseClient.client, connectorType: ConnectorTypes.jira, connectorId: '123', }); diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.ts b/x-pack/plugins/case/server/client/configure/get_mappings.ts index a2d2711264b13..5dd90efd8a2d7 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.ts @@ -5,20 +5,31 @@ * 2.0. */ +import { SavedObjectsClientContract } from 'src/core/server'; +import { ActionsClient } from '../../../../actions/server'; import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; -import { CaseClientFactoryArguments, MappingsClient } from '../types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects'; +import { ConnectorMappingsServiceSetup } from '../../services'; +import { CaseClientHandler } from '..'; -export const getMappings = ({ +interface GetMappingsArgs { + savedObjectsClient: SavedObjectsClientContract; + connectorMappingsService: ConnectorMappingsServiceSetup; + actionsClient: ActionsClient; + caseClient: CaseClientHandler; + connectorType: string; + connectorId: string; +} + +export const getMappings = async ({ savedObjectsClient, connectorMappingsService, -}: CaseClientFactoryArguments) => async ({ actionsClient, caseClient, connectorType, connectorId, -}: MappingsClient): Promise => { +}: GetMappingsArgs): Promise => { if (connectorType === ConnectorTypes.none) { return []; } diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 4daa4d1c0bd8b..8a085bf29f214 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -5,9 +5,11 @@ * 2.0. */ -import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { createCaseClient } from '.'; +import { + elasticsearchServiceMock, + savedObjectsClientMock, +} from '../../../../../src/core/server/mocks'; +import { nullUser } from '../common'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -16,87 +18,30 @@ import { createAlertServiceMock, } from '../services/mocks'; -import { create } from './cases/create'; -import { get } from './cases/get'; -import { update } from './cases/update'; -import { push } from './cases/push'; -import { addComment } from './comments/add'; -import { getFields } from './configure/get_fields'; -import { getMappings } from './configure/get_mappings'; -import { updateAlertsStatus } from './alerts/update_status'; -import { get as getUserActions } from './user_actions/get'; -import { get as getAlerts } from './alerts/get'; -import type { CasesRequestHandlerContext } from '../types'; - -jest.mock('./cases/create'); -jest.mock('./cases/update'); -jest.mock('./cases/get'); -jest.mock('./cases/push'); -jest.mock('./comments/add'); -jest.mock('./alerts/update_status'); -jest.mock('./alerts/get'); -jest.mock('./user_actions/get'); -jest.mock('./configure/get_fields'); -jest.mock('./configure/get_mappings'); +jest.mock('./client'); +import { CaseClientHandler } from './client'; +import { createExternalCaseClient } from './index'; +const esClient = elasticsearchServiceMock.createElasticsearchClient(); const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); const caseService = createCaseServiceMock(); const connectorMappingsService = connectorMappingsServiceMock(); -const request = {} as KibanaRequest; -const response = kibanaResponseFactory; const savedObjectsClient = savedObjectsClientMock.create(); const userActionService = createUserActionServiceMock(); -const context = {} as CasesRequestHandlerContext; -const createMock = create as jest.Mock; -const getMock = get as jest.Mock; -const updateMock = update as jest.Mock; -const pushMock = push as jest.Mock; -const addCommentMock = addComment as jest.Mock; -const updateAlertsStatusMock = updateAlertsStatus as jest.Mock; -const getAlertsStatusMock = getAlerts as jest.Mock; -const getFieldsMock = getFields as jest.Mock; -const getMappingsMock = getMappings as jest.Mock; -const getUserActionsMock = getUserActions as jest.Mock; - -describe('createCaseClient()', () => { +describe('createExternalCaseClient()', () => { test('it creates the client correctly', async () => { - createCaseClient({ + createExternalCaseClient({ + scopedClusterClient: esClient, alertsService, caseConfigureService, caseService, connectorMappingsService, - context, - request, - response, + user: nullUser, savedObjectsClient, userActionService, }); - - [ - createMock, - getMock, - updateMock, - pushMock, - addCommentMock, - updateAlertsStatusMock, - getAlertsStatusMock, - getFieldsMock, - getMappingsMock, - getUserActionsMock, - ].forEach((method) => - expect(method).toHaveBeenCalledWith({ - caseConfigureService, - caseService, - connectorMappingsService, - request, - response, - savedObjectsClient, - userActionService, - alertsService, - context, - }) - ); + expect(CaseClientHandler).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index e15b9fc766562..900b5a92ebf92 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -5,41 +5,16 @@ * 2.0. */ -import { - CaseClientFactoryArguments, - CaseClient, - CaseClientFactoryMethods, - CaseClientMethods, -} from './types'; -import { create } from './cases/create'; -import { get } from './cases/get'; -import { update } from './cases/update'; -import { push } from './cases/push'; -import { addComment } from './comments/add'; -import { getFields } from './configure/get_fields'; -import { getMappings } from './configure/get_mappings'; -import { updateAlertsStatus } from './alerts/update_status'; -import { get as getUserActions } from './user_actions/get'; -import { get as getAlerts } from './alerts/get'; +import { CaseClientFactoryArguments, CaseClient } from './types'; +import { CaseClientHandler } from './client'; +export { CaseClientHandler } from './client'; export { CaseClient } from './types'; -export const createCaseClient = (args: CaseClientFactoryArguments): CaseClient => { - const methods: CaseClientFactoryMethods = { - create, - get, - update, - push, - addComment, - getAlerts, - getFields, - getMappings, - getUserActions, - updateAlertsStatus, - }; - - return (Object.keys(methods) as CaseClientMethods[]).reduce((client, method) => { - client[method] = methods[method](args); - return client; - }, {} as CaseClient); +/** + * Create a CaseClientHandler to external services (other plugins). + */ +export const createExternalCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { + const client = new CaseClientHandler(clientArgs); + return client; }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index b2a07e36b3aed..302745913babb 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -5,10 +5,7 @@ * 2.0. */ -import { omit } from 'lodash/fp'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server/http'; -import { loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { AlertServiceContract, CaseConfigureService, @@ -17,12 +14,11 @@ import { ConnectorMappingsService, } from '../services'; import { CaseClient } from './types'; -import { authenticationMock, createActionsClient } from '../routes/api/__fixtures__'; -import { createCaseClient } from '.'; -import type { CasesRequestHandlerContext } from '../types'; +import { authenticationMock } from '../routes/api/__fixtures__'; +import { createExternalCaseClient } from '.'; -export type CaseClientMock = jest.Mocked; -export const createCaseClientMock = (): CaseClientMock => ({ +export type CaseClientPluginContractMock = jest.Mocked; +export const createExternalCaseClientMock = (): CaseClientPluginContractMock => ({ addComment: jest.fn(), create: jest.fn(), get: jest.fn(), @@ -50,18 +46,15 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ alertsService: jest.Mocked; }; }> => { - const actionsMock = createActionsClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + // const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); - const request = {} as KibanaRequest; - const response = kibanaResponseFactory; - const caseServicePlugin = new CaseService(log); + const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); + const caseService = new CaseService(log, auth); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const connectorMappingsService = await connectorMappingsServicePlugin.setup(); @@ -76,33 +69,15 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ getAlerts: jest.fn(), }; - const context = { - core: { - savedObjects: { - client: savedObjectsClient, - }, - }, - actions: { getActionsClient: () => actionsMock }, - case: { - getCaseClient: () => caseClient, - }, - securitySolution: { - getAppClient: () => ({ - getSignalsIndex: () => '.siem-signals', - }), - }, - }; - - const caseClient = createCaseClient({ + const caseClient = createExternalCaseClient({ savedObjectsClient, - request, - response, + user: auth.getCurrentUser(), caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - context: (omit(omitFromContext, context) as unknown) as CasesRequestHandlerContext, + scopedClusterClient: esClient, }); return { client: caseClient, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 8778aa46a2d24..a8f64227daf83 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { KibanaRequest, KibanaResponseFactory, SavedObjectsClientContract } from 'kibana/server'; +import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CasePostRequest, @@ -13,10 +13,12 @@ import { CasesPatchRequest, CasesResponse, CaseStatuses, + CollectionWithSubCaseResponse, CommentRequest, ConnectorMappingsAttributes, GetFieldsResponse, CaseUserActionsResponse, + User, } from '../../common/api'; import { CaseConfigureServiceSetup, @@ -25,32 +27,21 @@ import { AlertServiceContract, } from '../services'; import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; -import type { CasesRequestHandlerContext } from '../types'; import { CaseClientGetAlertsResponse } from './alerts/types'; -export interface CaseClientCreate { - theCase: CasePostRequest; -} - -export interface CaseClientUpdate { - caseClient: CaseClient; - cases: CasesPatchRequest; -} - export interface CaseClientGet { id: string; includeComments?: boolean; + includeSubCaseComments?: boolean; } export interface CaseClientPush { actionsClient: ActionsClient; - caseClient: CaseClient; caseId: string; connectorId: string; } export interface CaseClientAddComment { - caseClient: CaseClient; caseId: string; comment: CommentRequest; } @@ -58,10 +49,12 @@ export interface CaseClientAddComment { export interface CaseClientUpdateAlertsStatus { ids: string[]; status: CaseStatuses; + indices: Set; } export interface CaseClientGetAlerts { ids: string[]; + indices: Set; } export interface CaseClientGetUserActions { @@ -70,21 +63,19 @@ export interface CaseClientGetUserActions { export interface MappingsClient { actionsClient: ActionsClient; - caseClient: CaseClient; connectorId: string; connectorType: string; } export interface CaseClientFactoryArguments { + scopedClusterClient: ElasticsearchClient; caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; connectorMappingsService: ConnectorMappingsServiceSetup; - request: KibanaRequest; - response: KibanaResponseFactory; + user: User; savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; - context?: Omit; } export interface ConfigureFields { @@ -92,25 +83,25 @@ export interface ConfigureFields { connectorId: string; connectorType: string; } + +/** + * This represents the interface that other plugins can access. + */ export interface CaseClient { - addComment: (args: CaseClientAddComment) => Promise; - create: (args: CaseClientCreate) => Promise; - get: (args: CaseClientGet) => Promise; - getAlerts: (args: CaseClientGetAlerts) => Promise; - getFields: (args: ConfigureFields) => Promise; - getMappings: (args: MappingsClient) => Promise; - getUserActions: (args: CaseClientGetUserActions) => Promise; - push: (args: CaseClientPush) => Promise; - update: (args: CaseClientUpdate) => Promise; - updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise; + addComment(args: CaseClientAddComment): Promise; + create(theCase: CasePostRequest): Promise; + get(args: CaseClientGet): Promise; + getAlerts(args: CaseClientGetAlerts): Promise; + getFields(args: ConfigureFields): Promise; + getMappings(args: MappingsClient): Promise; + getUserActions(args: CaseClientGetUserActions): Promise; + push(args: CaseClientPush): Promise; + update(args: CasesPatchRequest): Promise; + updateAlertsStatus(args: CaseClientUpdateAlertsStatus): Promise; } -export type CaseClientFactoryMethod = ( - factoryArgs: CaseClientFactoryArguments -) => (methodArgs: any) => Promise; - -export type CaseClientMethods = keyof CaseClient; - -export type CaseClientFactoryMethods = { - [K in CaseClientMethods]: CaseClientFactoryMethod; -}; +export interface MappingsClient { + actionsClient: ActionsClient; + connectorId: string; + connectorType: string; +} diff --git a/x-pack/plugins/case/server/client/user_actions/get.ts b/x-pack/plugins/case/server/client/user_actions/get.ts index e83a9e3484262..8a4e45f71b9ca 100644 --- a/x-pack/plugins/case/server/client/user_actions/get.ts +++ b/x-pack/plugins/case/server/client/user_actions/get.ts @@ -5,16 +5,22 @@ * 2.0. */ +import { SavedObjectsClientContract } from 'kibana/server'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api'; -import { CaseClientGetUserActions, CaseClientFactoryArguments } from '../types'; +import { CaseUserActionServiceSetup } from '../../services'; -export const get = ({ +interface GetParams { + savedObjectsClient: SavedObjectsClientContract; + userActionService: CaseUserActionServiceSetup; + caseId: string; +} + +export const get = async ({ savedObjectsClient, userActionService, -}: CaseClientFactoryArguments) => async ({ caseId, -}: CaseClientGetUserActions): Promise => { +}: GetParams): Promise => { const userActions = await userActionService.getUserActions({ client: savedObjectsClient, caseId, diff --git a/x-pack/plugins/case/server/common/index.ts b/x-pack/plugins/case/server/common/index.ts new file mode 100644 index 0000000000000..0960b28b3d25a --- /dev/null +++ b/x-pack/plugins/case/server/common/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export * from './models'; +export * from './utils'; diff --git a/x-pack/plugins/case/server/common/models/commentable_case.ts b/x-pack/plugins/case/server/common/models/commentable_case.ts new file mode 100644 index 0000000000000..9827118ee8e29 --- /dev/null +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -0,0 +1,300 @@ +/* + * 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 { + SavedObject, + SavedObjectReference, + SavedObjectsClientContract, + SavedObjectsUpdateResponse, +} from 'src/core/server'; +import { + AssociationType, + CaseSettings, + CaseStatuses, + CaseType, + CollectionWithSubCaseResponse, + CollectWithSubCaseResponseRt, + CommentAttributes, + CommentPatchRequest, + CommentRequest, + CommentType, + ESCaseAttributes, + SubCaseAttributes, + User, +} from '../../../common/api'; +import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; +import { + flattenCommentSavedObjects, + flattenSubCaseSavedObject, + transformNewComment, +} from '../../routes/api/utils'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { CaseServiceSetup } from '../../services'; +import { countAlertsForID } from '../index'; + +interface UpdateCommentResp { + comment: SavedObjectsUpdateResponse; + commentableCase: CommentableCase; +} + +interface NewCommentResp { + comment: SavedObject; + commentableCase: CommentableCase; +} + +interface CommentableCaseParams { + collection: SavedObject; + subCase?: SavedObject; + soClient: SavedObjectsClientContract; + service: CaseServiceSetup; +} + +/** + * This class represents a case that can have a comment attached to it. This includes + * a Sub Case, Case, and Collection. + */ +export class CommentableCase { + private readonly collection: SavedObject; + private readonly subCase?: SavedObject; + private readonly soClient: SavedObjectsClientContract; + private readonly service: CaseServiceSetup; + constructor({ collection, subCase, soClient, service }: CommentableCaseParams) { + this.collection = collection; + this.subCase = subCase; + this.soClient = soClient; + this.service = service; + } + + public get status(): CaseStatuses { + return this.subCase?.attributes.status ?? this.collection.attributes.status; + } + + /** + * This property is used to abstract away which element is actually being acted upon in this class. + * If the sub case was initialized then it will be the focus of creating comments. So if you want the id + * of the saved object that the comment is primarily being attached to use this property. + * + * This is a little confusing because the created comment will have references to both the sub case and the + * collection but from the UI's perspective only the sub case really has the comment attached to it. + */ + public get id(): string { + return this.subCase?.id ?? this.collection.id; + } + + public get settings(): CaseSettings { + return this.collection.attributes.settings; + } + + /** + * These functions break the abstraction of this class but they are needed to build the comment user action item. + * Another potential solution would be to implement another function that handles creating the user action in this + * class so that we don't need to expose these properties. + */ + public get caseId(): string { + return this.collection.id; + } + + public get subCaseId(): string | undefined { + return this.subCase?.id; + } + + private buildRefsToCase(): SavedObjectReference[] { + const subCaseSOType = SUB_CASE_SAVED_OBJECT; + const caseSOType = CASE_SAVED_OBJECT; + return [ + { + type: caseSOType, + name: `associated-${caseSOType}`, + id: this.collection.id, + }, + ...(this.subCase + ? [{ type: subCaseSOType, name: `associated-${subCaseSOType}`, id: this.subCase.id }] + : []), + ]; + } + + private async update({ date, user }: { date: string; user: User }): Promise { + let updatedSubCaseAttributes: SavedObject | undefined; + + if (this.subCase) { + const updatedSubCase = await this.service.patchSubCase({ + client: this.soClient, + subCaseId: this.subCase.id, + updatedAttributes: { + updated_at: date, + updated_by: { + ...user, + }, + }, + version: this.subCase.version, + }); + + updatedSubCaseAttributes = { + ...this.subCase, + attributes: { + ...this.subCase.attributes, + ...updatedSubCase.attributes, + }, + version: updatedSubCase.version ?? this.subCase.version, + }; + } + + const updatedCase = await this.service.patchCase({ + client: this.soClient, + caseId: this.collection.id, + updatedAttributes: { + updated_at: date, + updated_by: { ...user }, + }, + version: this.collection.version, + }); + + // this will contain the updated sub case information if the sub case was defined initially + return new CommentableCase({ + collection: { + ...this.collection, + attributes: { + ...this.collection.attributes, + ...updatedCase.attributes, + }, + version: updatedCase.version ?? this.collection.version, + }, + subCase: updatedSubCaseAttributes, + soClient: this.soClient, + service: this.service, + }); + } + + /** + * Update a comment and update the corresponding case's update_at and updated_by fields. + */ + public async updateComment({ + updateRequest, + updatedAt, + user, + }: { + updateRequest: CommentPatchRequest; + updatedAt: string; + user: User; + }): Promise { + const { id, version, ...queryRestAttributes } = updateRequest; + + const [comment, commentableCase] = await Promise.all([ + this.service.patchComment({ + client: this.soClient, + commentId: id, + updatedAttributes: { + ...queryRestAttributes, + updated_at: updatedAt, + updated_by: user, + }, + version, + }), + this.update({ date: updatedAt, user }), + ]); + return { + comment, + commentableCase, + }; + } + + /** + * Create a new comment on the appropriate case. This updates the case's updated_at and updated_by fields. + */ + public async createComment({ + createdDate, + user, + commentReq, + }: { + createdDate: string; + user: User; + commentReq: CommentRequest; + }): Promise { + if (commentReq.type === CommentType.alert) { + if (this.status === CaseStatuses.closed) { + throw Boom.badRequest('Alert cannot be attached to a closed case'); + } + + if (!this.subCase && this.collection.attributes.type === CaseType.collection) { + throw Boom.badRequest('Alert cannot be attached to a collection case'); + } + } + + const [comment, commentableCase] = await Promise.all([ + this.service.postNewComment({ + client: this.soClient, + attributes: transformNewComment({ + associationType: this.subCase ? AssociationType.subCase : AssociationType.case, + createdDate, + ...commentReq, + ...user, + }), + references: this.buildRefsToCase(), + }), + this.update({ date: createdDate, user }), + ]); + return { + comment, + commentableCase, + }; + } + + private formatCollectionForEncoding(totalComment: number) { + return { + id: this.collection.id, + version: this.collection.version ?? '0', + totalComment, + ...this.collection.attributes, + connector: transformESConnectorToCaseConnector(this.collection.attributes.connector), + }; + } + + public async encode(): Promise { + const collectionCommentStats = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }); + + if (this.subCase) { + const subCaseComments = await this.service.getAllSubCaseComments({ + client: this.soClient, + id: this.subCase.id, + }); + + return CollectWithSubCaseResponseRt.encode({ + subCase: flattenSubCaseSavedObject({ + savedObject: this.subCase, + comments: subCaseComments.saved_objects, + totalAlerts: countAlertsForID({ comments: subCaseComments, id: this.subCase.id }), + }), + ...this.formatCollectionForEncoding(collectionCommentStats.total), + }); + } + + const collectionComments = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: collectionCommentStats.total, + }, + }); + + return CollectWithSubCaseResponseRt.encode({ + comments: flattenCommentSavedObjects(collectionComments.saved_objects), + totalAlerts: countAlertsForID({ comments: collectionComments, id: this.collection.id }), + ...this.formatCollectionForEncoding(collectionCommentStats.total), + }); + } +} diff --git a/x-pack/plugins/case/server/common/models/index.ts b/x-pack/plugins/case/server/common/models/index.ts new file mode 100644 index 0000000000000..189090c91c81c --- /dev/null +++ b/x-pack/plugins/case/server/common/models/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './commentable_case'; diff --git a/x-pack/plugins/case/server/common/utils.test.ts b/x-pack/plugins/case/server/common/utils.test.ts new file mode 100644 index 0000000000000..d89feb009f806 --- /dev/null +++ b/x-pack/plugins/case/server/common/utils.test.ts @@ -0,0 +1,235 @@ +/* + * 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 { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common/api'; +import { transformNewComment } from '../routes/api/utils'; +import { combineFilters, countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; + +interface CommentReference { + ids: string[]; + comments: CommentRequest[]; +} + +function createCommentFindResponse( + commentRequests: CommentReference[] +): SavedObjectsFindResponse { + const resp: SavedObjectsFindResponse = { + page: 0, + per_page: 0, + total: 0, + saved_objects: [], + }; + + for (const { ids, comments } of commentRequests) { + for (const id of ids) { + for (const comment of comments) { + resp.saved_objects.push({ + id: '', + references: [{ id, type: '', name: '' }], + score: 0, + type: '', + attributes: transformNewComment({ + ...comment, + associationType: AssociationType.case, + createdDate: '', + }), + }); + } + } + } + + return resp; +} + +describe('common utils', () => { + describe('combineFilters', () => { + it("creates a filter string with two values and'd together", () => { + expect(combineFilters(['a', 'b'], 'AND')).toBe('(a AND b)'); + }); + + it('creates a filter string with three values or together', () => { + expect(combineFilters(['a', 'b', 'c'], 'OR')).toBe('(a OR b OR c)'); + }); + + it('ignores empty strings', () => { + expect(combineFilters(['', 'a', '', 'b'], 'AND')).toBe('(a AND b)'); + }); + + it('returns an empty string if all filters are empty strings', () => { + expect(combineFilters(['', ''], 'OR')).toBe(''); + }); + + it('returns an empty string if the filters are undefined', () => { + expect(combineFilters(undefined, 'OR')).toBe(''); + }); + + it('returns a value without parenthesis when only a single filter is provided', () => { + expect(combineFilters(['a'], 'OR')).toBe('a'); + }); + + it('returns a string without parenthesis when only a single non empty filter is provided', () => { + expect(combineFilters(['', ''], 'AND')).toBe(''); + }); + }); + + describe('countAlerts', () => { + it('returns 0 when no alerts are found', () => { + expect( + countAlerts( + createCommentFindResponse([ + { ids: ['1'], comments: [{ comment: '', type: CommentType.user }] }, + ]).saved_objects[0] + ) + ).toBe(0); + }); + + it('returns 3 alerts for a single generated alert comment', () => { + expect( + countAlerts( + createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b', 'c'], + index: '', + type: CommentType.generatedAlert, + }, + ], + }, + ]).saved_objects[0] + ) + ).toBe(3); + }); + + it('returns 3 alerts for a single alert comment', () => { + expect( + countAlerts( + createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b', 'c'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]).saved_objects[0] + ) + ).toBe(3); + }); + }); + + describe('groupTotalAlertsByID', () => { + it('returns a map with one entry and 2 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + { + comment: '', + type: CommentType.user, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([['1', 2]]) + ); + }); + + it('returns a map with two entry, 2 alerts, and 0 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + { + ids: ['2'], + comments: [ + { + comment: '', + type: CommentType.user, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([ + ['1', 2], + ['2', 0], + ]) + ); + }); + + it('returns a map with two entry, 2 alerts, and 2 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1', '2'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([ + ['1', 2], + ['2', 2], + ]) + ); + }); + }); + + describe('countAlertsForID', () => { + it('returns 2 alerts for id 1 when the map has multiple entries', () => { + expect( + countAlertsForID({ + id: '1', + comments: createCommentFindResponse([ + { + ids: ['1', '2'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]), + }) + ).toEqual(2); + }); + }); +}); diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts new file mode 100644 index 0000000000000..a3ac0361569d5 --- /dev/null +++ b/x-pack/plugins/case/server/common/utils.ts @@ -0,0 +1,127 @@ +/* + * 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 { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; +import { CaseStatuses, CommentAttributes, CommentType, User } from '../../common/api'; +import { AlertInfo, getAlertIndicesAndIDs } from '../routes/api/utils'; + +/** + * Default sort field for querying saved objects. + */ +export const defaultSortField = 'created_at'; + +/** + * Default unknown user + */ +export const nullUser: User = { username: null, full_name: null, email: null }; + +/** + * Adds the ids and indices to a map of statuses + */ +export function addAlertInfoToStatusMap({ + comment, + statusMap, + status, +}: { + comment: CommentAttributes; + statusMap: Map; + status: CaseStatuses; +}) { + const newAlertInfo = getAlertIndicesAndIDs([comment]); + + // combine the already accumulated ids and indices with the new ones from this alert comment + if (newAlertInfo.ids.length > 0 && newAlertInfo.indices.size > 0) { + const accAlertInfo = statusMap.get(status) ?? { ids: [], indices: new Set() }; + accAlertInfo.ids.push(...newAlertInfo.ids); + accAlertInfo.indices = new Set([ + ...accAlertInfo.indices.values(), + ...newAlertInfo.indices.values(), + ]); + statusMap.set(status, accAlertInfo); + } +} + +/** + * Combines multiple filter expressions using the specified operator and parenthesis if multiple expressions exist. + * This will ignore empty string filters. If a single valid filter is found it will not wrap in parenthesis. + * + * @param filters an array of filters to combine using the specified operator + * @param operator AND or OR + */ +export const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { + const noEmptyStrings = filters?.filter((value) => value !== ''); + const joinedExp = noEmptyStrings?.join(` ${operator} `); + // if undefined or an empty string + if (!joinedExp) { + return ''; + } else if ((noEmptyStrings?.length ?? 0) > 1) { + // if there were multiple filters, wrap them in () + return `(${joinedExp})`; + } else { + // return a single value not wrapped in () + return joinedExp; + } +}; + +/** + * Counts the total alert IDs within a single comment. + */ +export const countAlerts = (comment: SavedObjectsFindResult) => { + let totalAlerts = 0; + if ( + comment.attributes.type === CommentType.alert || + comment.attributes.type === CommentType.generatedAlert + ) { + if (Array.isArray(comment.attributes.alertId)) { + totalAlerts += comment.attributes.alertId.length; + } else { + totalAlerts++; + } + } + return totalAlerts; +}; + +/** + * Count the number of alerts for each id in the alert's references. This will result + * in a map with entries for both the collection and the individual sub cases. So the resulting + * size of the map will not equal the total number of sub cases. + */ +export const groupTotalAlertsByID = ({ + comments, +}: { + comments: SavedObjectsFindResponse; +}): Map => { + return comments.saved_objects.reduce((acc, alertsInfo) => { + const alertTotalForComment = countAlerts(alertsInfo); + for (const alert of alertsInfo.references) { + if (alert.id) { + const totalAlerts = acc.get(alert.id); + + if (totalAlerts !== undefined) { + acc.set(alert.id, totalAlerts + alertTotalForComment); + } else { + acc.set(alert.id, alertTotalForComment); + } + } + } + + return acc; + }, new Map()); +}; + +/** + * Counts the total alert IDs for a single case or sub case ID. + */ +export const countAlertsForID = ({ + comments, + id, +}: { + comments: SavedObjectsFindResponse; + id: string; +}): number | undefined => { + return groupTotalAlertsByID({ comments }).get(id); +}; diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index 4a025fd980fe2..6b7e395bae4dc 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -10,7 +10,16 @@ import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsMock } from '../../../../actions/server/mocks'; import { validateParams } from '../../../../actions/server/lib'; -import { ConnectorTypes, CommentType, CaseStatuses } from '../../../common/api'; +import { + ConnectorTypes, + CommentType, + CaseStatuses, + CaseType, + AssociationType, + CaseResponse, + CasesResponse, + CollectionWithSubCaseResponse, +} from '../../../common/api'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -20,12 +29,12 @@ import { } from '../../services/mocks'; import { CaseActionType, CaseActionTypeExecutorOptions, CaseExecutorParams } from './types'; import { getActionType } from '.'; -import { createCaseClientMock } from '../../client/mocks'; +import { createExternalCaseClientMock } from '../../client/mocks'; -const mockCaseClient = createCaseClientMock(); +const mockCaseClient = createExternalCaseClientMock(); jest.mock('../../client', () => ({ - createCaseClient: () => mockCaseClient, + createExternalCaseClient: () => mockCaseClient, })); const services = actionsMock.createServices(); @@ -699,9 +708,7 @@ describe('case connector', () => { expect(validateParams(caseActionType, params)).toEqual(params); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('succeeds when type is an alert', () => { + it('succeeds when type is an alert', () => { const params: Record = { subAction: 'addComment', subActionParams: { @@ -727,26 +734,6 @@ describe('case connector', () => { }).toThrow(); }); - // TODO: Remove it when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it('fails when type is an alert', () => { - const params: Record = { - subAction: 'addComment', - subActionParams: { - caseId: 'case-id', - comment: { - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - }, - }, - }; - - expect(() => { - validateParams(caseActionType, params); - }).toThrow(); - }); - it('fails when missing attributes: type user', () => { const allParams = { type: CommentType.user, @@ -769,9 +756,7 @@ describe('case connector', () => { }); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('fails when missing attributes: type alert', () => { + it('fails when missing attributes: type alert', () => { const allParams = { type: CommentType.alert, comment: 'a comment', @@ -813,9 +798,7 @@ describe('case connector', () => { }); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('fails when excess attributes are provided: type alert', () => { + it('fails when excess attributes are provided: type alert', () => { ['comment'].forEach((attribute) => { const params: Record = { subAction: 'addComment', @@ -863,10 +846,11 @@ describe('case connector', () => { describe('create', () => { it('executes correctly', async () => { - const createReturn = { + const createReturn: CaseResponse = { id: 'mock-it', comments: [], totalComment: 0, + totalAlerts: 0, closed_at: null, closed_by: null, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, @@ -878,6 +862,7 @@ describe('case connector', () => { }, title: 'Case from case connector!!', tags: ['case', 'connector'], + type: CaseType.collection, description: 'Yo fields!!', external_service: null, status: CaseStatuses.open, @@ -926,17 +911,15 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: createReturn }); expect(mockCaseClient.create).toHaveBeenCalledWith({ - theCase: { - ...params.subActionParams, - connector: { - id: 'jira', - name: 'Jira', - type: '.jira', - fields: { - issueType: '10006', - priority: 'High', - parent: null, - }, + ...params.subActionParams, + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, }, }, }); @@ -945,7 +928,7 @@ describe('case connector', () => { describe('update', () => { it('executes correctly', async () => { - const updateReturn = [ + const updateReturn: CasesResponse = [ { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { @@ -973,6 +956,8 @@ describe('case connector', () => { tags: ['defacement'], title: 'Update title', totalComment: 0, + totalAlerts: 0, + type: CaseType.collection, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', @@ -1015,41 +1000,45 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: updateReturn }); expect(mockCaseClient.update).toHaveBeenCalledWith({ - caseClient: mockCaseClient, // Null values have been striped out. - cases: { - cases: [ - { - id: 'case-id', - version: '123', - title: 'Update title', - }, - ], - }, + cases: [ + { + id: 'case-id', + version: '123', + title: 'Update title', + }, + ], }); }); }); describe('addComment', () => { it('executes correctly', async () => { - const commentReturn = { + const commentReturn: CollectionWithSubCaseResponse = { id: 'mock-it', totalComment: 0, + version: 'WzksMV0=', + closed_at: null, closed_by: null, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, + created_by: { + full_name: 'Awesome D00d', + email: 'd00d@awesome.com', + username: 'awesome', + }, description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.collection, updated_at: null, updated_by: null, - version: 'WzksMV0=', comments: [ { + associationType: AssociationType.case, comment: 'a comment', type: CommentType.user as const, created_at: '2020-10-23T21:54:48.952Z', @@ -1097,7 +1086,6 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: commentReturn }); expect(mockCaseClient.addComment).toHaveBeenCalledWith({ - caseClient: mockCaseClient, caseId: 'case-id', comment: { comment: 'a comment', diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 9907aa5b3cd3a..34b407616cfe4 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -7,11 +7,15 @@ import { curry } from 'lodash'; -import { KibanaRequest, kibanaResponseFactory } from '../../../../../../src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; -import { CasePatchRequest, CasePostRequest } from '../../../common/api'; -import { createCaseClient } from '../../client'; -import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema'; +import { + CasePatchRequest, + CasePostRequest, + CommentRequest, + CommentType, +} from '../../../common/api'; +import { createExternalCaseClient } from '../../client'; +import { CaseExecutorParamsSchema, CaseConfigurationSchema, CommentSchemaType } from './schema'; import { CaseExecutorResponse, ExecutorSubActionAddCommentParams, @@ -19,9 +23,9 @@ import { CaseActionTypeExecutorOptions, } from './types'; import * as i18n from './translations'; -import type { CasesRequestHandlerContext } from '../../types'; -import { GetActionTypeParams } from '..'; +import { GetActionTypeParams, isCommentGeneratedAlert } from '..'; +import { nullUser } from '../../common'; const supportedSubActions: string[] = ['create', 'update', 'addComment']; @@ -69,18 +73,17 @@ async function executor( const { subAction, subActionParams } = params; let data: CaseExecutorResponse | null = null; - const { savedObjectsClient } = services; - const caseClient = createCaseClient({ + const { savedObjectsClient, scopedClusterClient } = services; + const caseClient = createExternalCaseClient({ savedObjectsClient, - request: {} as KibanaRequest, - response: kibanaResponseFactory, + scopedClusterClient, + // we might want the user information to be passed as part of the action request + user: nullUser, caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - // TODO: When case connector is enabled we should figure out how to pass the context. - context: {} as CasesRequestHandlerContext, }); if (!supportedSubActions.includes(subAction)) { @@ -90,7 +93,9 @@ async function executor( } if (subAction === 'create') { - data = await caseClient.create({ theCase: subActionParams as CasePostRequest }); + data = await caseClient.create({ + ...(subActionParams as CasePostRequest), + }); } if (subAction === 'update') { @@ -102,16 +107,39 @@ async function executor( {} as CasePatchRequest ); - data = await caseClient.update({ - caseClient, - cases: { cases: [updateParamsWithoutNullValues] }, - }); + data = await caseClient.update({ cases: [updateParamsWithoutNullValues] }); } if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - data = await caseClient.addComment({ caseClient, caseId, comment }); + const formattedComment = transformConnectorComment(comment); + data = await caseClient.addComment({ caseId, comment: formattedComment }); } return { status: 'ok', data: data ?? {}, actionId }; } + +/** + * This converts a connector style generated alert ({_id: string} | {_id: string}[]) to the expected format of addComment. + */ +export const transformConnectorComment = (comment: CommentSchemaType): CommentRequest => { + if (isCommentGeneratedAlert(comment)) { + const alertId: string[] = []; + if (Array.isArray(comment.alerts)) { + alertId.push( + ...comment.alerts.map((alert: { _id: string }) => { + return alert._id; + }) + ); + } else { + alertId.push(comment.alerts._id); + } + return { + type: CommentType.generatedAlert, + alertId, + index: comment.index, + }; + } else { + return comment; + } +}; diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 8d52a344308e1..cdeb00209f846 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -6,37 +6,48 @@ */ import { schema } from '@kbn/config-schema'; +import { CommentType } from '../../../common/api'; import { validateConnector } from './validators'; // Reserved for future implementation export const CaseConfigurationSchema = schema.object({}); const ContextTypeUserSchema = schema.object({ - type: schema.literal('user'), + type: schema.literal(CommentType.user), comment: schema.string(), }); -/** - * ContextTypeAlertSchema has been deleted. - * Comments of type alert need the siem signal index. - * Case connector is not being passed the context which contains the - * security solution app client which in turn provides the siem signal index. - * For that reason, we disable comments of type alert for the case connector until - * we figure out how to pass the security solution app client to the connector. - * See: x-pack/plugins/case/server/connectors/case/index.ts L76. - * - * The schema: - * - * const ContextTypeAlertSchema = schema.object({ - * type: schema.literal('alert'), - * alertId: schema.string(), - * index: schema.string(), - * }); - * - * Issue: https://github.com/elastic/kibana/issues/85750 - * */ - -export const CommentSchema = schema.oneOf([ContextTypeUserSchema]); +const AlertIDSchema = schema.object( + { + _id: schema.string(), + }, + { unknowns: 'ignore' } +); + +const ContextTypeAlertGroupSchema = schema.object({ + type: schema.literal(CommentType.generatedAlert), + alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]), + index: schema.string(), +}); + +export type ContextTypeGeneratedAlertType = typeof ContextTypeAlertGroupSchema.type; + +const ContextTypeAlertSchema = schema.object({ + type: schema.literal(CommentType.alert), + // allowing either an array or a single value to preserve the previous API of attaching a single alert ID + alertId: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), + index: schema.string(), +}); + +export type ContextTypeAlertSchemaType = typeof ContextTypeAlertSchema.type; + +export const CommentSchema = schema.oneOf([ + ContextTypeUserSchema, + ContextTypeAlertSchema, + ContextTypeAlertGroupSchema, +]); + +export type CommentSchemaType = typeof CommentSchema.type; const JiraFieldsSchema = schema.object({ issueType: schema.string(), diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts index 6a7dfd9c2e687..50ff104d7bad0 100644 --- a/x-pack/plugins/case/server/connectors/case/types.ts +++ b/x-pack/plugins/case/server/connectors/case/types.ts @@ -16,7 +16,7 @@ import { ConnectorSchema, CommentSchema, } from './schema'; -import { CaseResponse, CasesResponse } from '../../../common/api'; +import { CaseResponse, CasesResponse, CollectionWithSubCaseResponse } from '../../../common/api'; export type CaseConfiguration = TypeOf; export type Connector = TypeOf; @@ -29,7 +29,7 @@ export type ExecutorSubActionAddCommentParams = TypeOf< >; export type CaseExecutorParams = TypeOf; -export type CaseExecutorResponse = CaseResponse | CasesResponse; +export type CaseExecutorResponse = CaseResponse | CasesResponse | CollectionWithSubCaseResponse; export type CaseActionType = ActionType< CaseConfiguration, diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index 00809d81ca5f2..056ccff2733a7 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -5,14 +5,22 @@ * 2.0. */ -import { RegisterConnectorsArgs, ExternalServiceFormatterMapper } from './types'; +import { + RegisterConnectorsArgs, + ExternalServiceFormatterMapper, + CommentSchemaType, + ContextTypeGeneratedAlertType, + ContextTypeAlertSchemaType, +} from './types'; import { getActionType as getCaseConnector } from './case'; import { serviceNowITSMExternalServiceFormatter } from './servicenow/itsm_formatter'; import { serviceNowSIRExternalServiceFormatter } from './servicenow/sir_formatter'; import { jiraExternalServiceFormatter } from './jira/external_service_formatter'; import { resilientExternalServiceFormatter } from './resilient/external_service_formatter'; +import { CommentRequest, CommentType } from '../../common/api'; export * from './types'; +export { transformConnectorComment } from './case'; export const registerConnectors = ({ actionsRegisterType, @@ -41,3 +49,19 @@ export const externalServiceFormatters: ExternalServiceFormatterMapper = { '.jira': jiraExternalServiceFormatter, '.resilient': resilientExternalServiceFormatter, }; + +export const isCommentGeneratedAlert = ( + comment: CommentSchemaType | CommentRequest +): comment is ContextTypeGeneratedAlertType => { + return ( + comment.type === CommentType.generatedAlert && + 'alerts' in comment && + comment.alerts !== undefined + ); +}; + +export const isCommentAlert = ( + comment: CommentSchemaType +): comment is ContextTypeAlertSchemaType => { + return comment.type === CommentType.alert; +}; diff --git a/x-pack/plugins/case/server/connectors/types.ts b/x-pack/plugins/case/server/connectors/types.ts index 8e7eb91ad2dc6..ffda6f96ae3ba 100644 --- a/x-pack/plugins/case/server/connectors/types.ts +++ b/x-pack/plugins/case/server/connectors/types.ts @@ -23,6 +23,12 @@ import { AlertServiceContract, } from '../services'; +export { + ContextTypeGeneratedAlertType, + CommentSchemaType, + ContextTypeAlertSchemaType, +} from './case/schema'; + export interface GetActionTypeParams { logger: Logger; caseService: CaseServiceSetup; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 5d05db165f637..1c00c26a7c0b0 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -5,13 +5,7 @@ * 2.0. */ -import { - IContextProvider, - KibanaRequest, - KibanaResponseFactory, - Logger, - PluginInitializerContext, -} from 'kibana/server'; +import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; import { SecurityPluginSetup } from '../../security/server'; @@ -26,6 +20,7 @@ import { caseConnectorMappingsSavedObjectType, caseSavedObjectType, caseUserActionSavedObjectType, + subCaseSavedObjectType, } from './saved_object_types'; import { CaseConfigureService, @@ -39,7 +34,7 @@ import { AlertService, AlertServiceContract, } from './services'; -import { createCaseClient } from './client'; +import { CaseClientHandler, createExternalCaseClient } from './client'; import { registerConnectors } from './connectors'; import type { CasesRequestHandlerContext } from './types'; @@ -75,6 +70,7 @@ export class CasePlugin { core.savedObjects.registerType(caseConfigureSavedObjectType); core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); core.savedObjects.registerType(caseSavedObjectType); + core.savedObjects.registerType(subCaseSavedObjectType); core.savedObjects.registerType(caseUserActionSavedObjectType); this.log.debug( @@ -83,9 +79,10 @@ export class CasePlugin { )}] and plugins [${Object.keys(plugins)}]` ); - this.caseService = await new CaseService(this.log).setup({ - authentication: plugins.security != null ? plugins.security.authc : null, - }); + this.caseService = new CaseService( + this.log, + plugins.security != null ? plugins.security.authc : undefined + ); this.caseConfigureService = await new CaseConfigureService(this.log).setup(); this.connectorMappingsService = await new ConnectorMappingsService(this.log).setup(); this.userActionService = await new CaseUserActionService(this.log).setup(); @@ -125,23 +122,21 @@ export class CasePlugin { public start(core: CoreStart) { this.log.debug(`Starting Case Workflow`); - this.alertsService!.initialize(core.elasticsearch.client); const getCaseClientWithRequestAndContext = async ( context: CasesRequestHandlerContext, - request: KibanaRequest, - response: KibanaResponseFactory + request: KibanaRequest ) => { - return createCaseClient({ + const user = await this.caseService!.getUser({ request }); + return createExternalCaseClient({ + scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: core.savedObjects.getScopedClient(request), - request, - response, + user, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, connectorMappingsService: this.connectorMappingsService!, userActionService: this.userActionService!, alertsService: this.alertsService!, - context, }); }; @@ -171,18 +166,18 @@ export class CasePlugin { }): IContextProvider => { return async (context, request, response) => { const [{ savedObjects }] = await core.getStartServices(); + const user = await caseService.getUser({ request }); return { getCaseClient: () => { - return createCaseClient({ + return new CaseClientHandler({ + scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: savedObjects.getScopedClient(request), caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - context, - request, - response, + user, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts index 51ba684bf7a7b..66d3ffe5f23d1 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -7,6 +7,7 @@ import { AuthenticatedUser } from '../../../../../security/server'; import { securityMock } from '../../../../../security/server/mocks'; +import { nullUser } from '../../../common'; function createAuthenticationMock({ currentUser, @@ -14,7 +15,11 @@ function createAuthenticationMock({ const { authc } = securityMock.createSetup(); authc.getCurrentUser.mockReturnValue( currentUser !== undefined - ? currentUser + ? // if we pass in null then use the null user (has null for each field) this is the default behavior + // for the CaseService getUser method + currentUser !== null + ? currentUser + : nullUser : ({ email: 'd00d@awesome.com', username: 'awesome', diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 18730effdf55a..a33226bcde899 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -10,6 +10,7 @@ import { SavedObjectsErrorHelpers, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, + SavedObjectsFindOptions, } from 'src/core/server'; import { @@ -17,6 +18,7 @@ import { CASE_SAVED_OBJECT, CASE_CONFIGURE_SAVED_OBJECT, CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, CASE_USER_ACTION_SAVED_OBJECT, } from '../../../saved_object_types'; @@ -91,16 +93,29 @@ export const createMockSavedObjectsRepository = ({ throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } return result[0]; - } - - const result = caseSavedObject.filter((s) => s.id === id); - if (!result.length) { + } else if (type === CASE_SAVED_OBJECT) { + const result = caseSavedObject.filter((s) => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + } else { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return result[0]; }), - find: jest.fn((findArgs) => { - if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { + find: jest.fn((findArgs: SavedObjectsFindOptions) => { + // References can be an array so we need to loop through it looking for the bad-guy + const hasReferenceIncludeBadGuy = (args: SavedObjectsFindOptions) => { + const references = args.hasReference; + if (references) { + return Array.isArray(references) + ? references.some((ref) => ref.id === 'bad-guy') + : references.id === 'bad-guy'; + } else { + return false; + } + }; + if (hasReferenceIncludeBadGuy(findArgs)) { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } @@ -141,6 +156,16 @@ export const createMockSavedObjectsRepository = ({ }; } + // Currently not supporting sub cases in this mock library + if (findArgs.type === SUB_CASE_SAVED_OBJECT) { + return { + page: 1, + per_page: 0, + total: 0, + saved_objects: [], + }; + } + if (findArgs.type === CASE_USER_ACTION_SAVED_OBJECT) { return { page: 1, @@ -206,19 +231,22 @@ export const createMockSavedObjectsRepository = ({ }), update: jest.fn((type, id, attributes) => { if (type === CASE_COMMENT_SAVED_OBJECT) { - if (!caseCommentSavedObject.find((s) => s.id === id)) { + const foundComment = caseCommentSavedObject.findIndex((s: { id: string }) => s.id === id); + if (foundComment === -1) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - caseCommentSavedObject = [ - ...caseCommentSavedObject, - { - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - version: 'WzE3LDFd', - attributes, + const comment = caseCommentSavedObject[foundComment]; + caseCommentSavedObject.splice(foundComment, 1, { + ...comment, + id, + type, + updated_at: '2019-11-22T22:50:55.191Z', + version: 'WzE3LDFd', + attributes: { + ...comment.attributes, + ...attributes, }, - ]; + }); } else if (type === CASE_SAVED_OBJECT) { if (!caseSavedObject.find((s) => s.id === id)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index ed4d22046c581..b4230a05749a1 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -19,14 +19,10 @@ export const createRoute = async ( const router = httpService.createRouter(); const log = loggingSystemMock.create().get('case'); - - const caseServicePlugin = new CaseService(log); + const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); + const caseService = new CaseService(log, auth); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const connectorMappingsService = await connectorMappingsServicePlugin.setup(); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 514f77a8f953d..2fe0be3e08ede 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -7,7 +7,9 @@ import { SavedObject } from 'kibana/server'; import { + AssociationType, CaseStatuses, + CaseType, CaseUserActionAttributes, CommentAttributes, CommentType, @@ -46,6 +48,7 @@ export const mockCases: Array> = [ title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.individual, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { full_name: 'elastic', @@ -83,6 +86,7 @@ export const mockCases: Array> = [ title: 'Damaging Data Destruction Detected', status: CaseStatuses.open, tags: ['Data Destruction'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:00.900Z', updated_by: { full_name: 'elastic', @@ -124,6 +128,7 @@ export const mockCases: Array> = [ title: 'Another bad one', status: CaseStatuses.open, tags: ['LOLBins'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', @@ -169,6 +174,7 @@ export const mockCases: Array> = [ status: CaseStatuses.closed, title: 'Another bad one', tags: ['LOLBins'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', @@ -231,6 +237,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-1', attributes: { + associationType: AssociationType.case, comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user, created_at: '2019-11-25T21:55:00.177Z', @@ -262,6 +269,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-2', attributes: { + associationType: AssociationType.case, comment: 'Well I decided to update my comment. So what? Deal with it.', type: CommentType.user, created_at: '2019-11-25T21:55:14.633Z', @@ -294,6 +302,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-3', attributes: { + associationType: AssociationType.case, comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user, created_at: '2019-11-25T22:32:30.608Z', @@ -325,6 +334,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-4', attributes: { + associationType: AssociationType.case, type: CommentType.alert, index: 'test-index', alertId: 'test-id', @@ -357,6 +367,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-5', attributes: { + associationType: AssociationType.case, type: CommentType.alert, index: 'test-index-2', alertId: 'test-id-2', diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 74665ffdc5b16..492be96fb4aa9 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -5,12 +5,8 @@ * 2.0. */ -import { KibanaRequest, kibanaResponseFactory } from '../../../../../../../src/core/server'; -import { - loggingSystemMock, - elasticsearchServiceMock, -} from '../../../../../../../src/core/server/mocks'; -import { createCaseClient } from '../../../client'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { createExternalCaseClient } from '../../../client'; import { AlertService, CaseService, @@ -26,20 +22,18 @@ export const createRouteContext = async (client: any, badAuth = false) => { const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); - const esClientMock = elasticsearchServiceMock.createClusterClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + + const authc = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); - const caseServicePlugin = new CaseService(log); + const caseService = new CaseService(log, authc); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); const caseUserActionsServicePlugin = new CaseUserActionService(log); - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const userActionService = await caseUserActionsServicePlugin.setup(); const alertsService = new AlertService(); - alertsService.initialize(esClientMock); const context = ({ core: { @@ -51,24 +45,18 @@ export const createRouteContext = async (client: any, badAuth = false) => { case: { getCaseClient: () => caseClient, }, - securitySolution: { - getAppClient: () => ({ - getSignalsIndex: () => '.siem-signals', - }), - }, } as unknown) as CasesRequestHandlerContext; const connectorMappingsService = await connectorMappingsServicePlugin.setup(); - const caseClient = createCaseClient({ + const caseClient = createExternalCaseClient({ savedObjectsClient: client, - request: {} as KibanaRequest, - response: kibanaResponseFactory, + user: authc.getCurrentUser(), caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - context, + scopedClusterClient: esClient, }); return { context, services: { userActionService } }; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index 898e0a14d0e2d..bcbf1828e1fde 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -11,6 +11,7 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { AssociationType } from '../../../../../common/api'; export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { router.delete( @@ -20,19 +21,29 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); - const comments = await caseService.getAllCaseComments({ + const id = request.query?.subCaseID ?? request.params.case_id; + const comments = await caseService.getCommentsByAssociation({ client, - caseId: request.params.case_id, + id, + associationType: request.query?.subCaseID + ? AssociationType.subCase + : AssociationType.case, }); + await Promise.all( comments.saved_objects.map((comment) => caseService.deleteComment({ @@ -50,6 +61,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: request.params.case_id, + subCaseId: request.query?.subCaseID, commentId: comment.id, fields: ['comment'], }) diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 78c7623861b85..73307753a550d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -8,7 +8,7 @@ import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; @@ -23,13 +23,18 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: case_id: schema.string(), comment_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); const myComment = await caseService.getComment({ @@ -41,10 +46,13 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); } - const caseRef = myComment.references.find((c) => c.type === CASE_SAVED_OBJECT); - if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { + 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 ${request.params.case_id}).` + `This comment ${request.params.comment_id} does not exist in ${id}).` ); } @@ -60,7 +68,8 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: action: 'delete', actionAt: deleteDate, actionBy: { username, full_name, email }, - caseId: request.params.case_id, + caseId: id, + subCaseId: request.query?.subCaseID, commentId: request.params.comment_id, fields: ['comment'], }), diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index 91ac9259d2568..3431c340c791e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -5,6 +5,8 @@ * 2.0. */ +import * as rt from 'io-ts'; + import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; @@ -13,6 +15,7 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { + AssociationType, CommentsResponseRt, SavedObjectFindOptionsRt, throwErrors, @@ -20,6 +23,12 @@ import { import { RouteDeps } from '../../types'; import { escapeHatch, transformComments, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { defaultPage, defaultPerPage } from '../..'; + +const FindQueryParamsRt = rt.partial({ + ...SavedObjectFindOptionsRt.props, + subCaseID: rt.string, +}); export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { router.get( @@ -36,25 +45,41 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { try { const client = context.core.savedObjects.client; const query = pipe( - SavedObjectFindOptionsRt.decode(request.query), + FindQueryParamsRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) ); + const id = query.subCaseID ?? request.params.case_id; + const associationType = query.subCaseID ? AssociationType.subCase : AssociationType.case; const args = query ? { + caseService, client, - caseId: request.params.case_id, + id, options: { - ...query, + // 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', + ...query, }, + associationType, } : { + caseService, client, - caseId: request.params.case_id, + id, + options: { + page: defaultPage, + perPage: defaultPerPage, + sortField: 'created_at', + }, + associationType, }; - const theComments = await caseService.getAllCaseComments(args); + const theComments = await caseService.getCommentsByAssociation(args); return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 72105662dafb5..730b1b92a8a07 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -7,10 +7,12 @@ import { schema } from '@kbn/config-schema'; -import { AllCommentsResponseRt } from '../../../../../common/api'; +import { SavedObjectsFindResponse } from 'kibana/server'; +import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { flattenCommentSavedObjects, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { defaultSortField } from '../../../../common'; export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { router.get( @@ -20,15 +22,38 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + includeSubCaseComments: schema.maybe(schema.boolean()), + subCaseID: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; - const comments = await caseService.getAllCaseComments({ - client, - caseId: request.params.case_id, - }); + let comments: SavedObjectsFindResponse; + + if (request.query?.subCaseID) { + comments = await caseService.getAllSubCaseComments({ + client, + id: request.query.subCaseID, + options: { + sortField: defaultSortField, + }, + }); + } else { + comments = await caseService.getAllCaseComments({ + client, + id: request.params.case_id, + includeSubCaseComments: request.query?.includeSubCaseComments, + options: { + sortField: defaultSortField, + }, + }); + } + return response.ok({ body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts index 33dc24d776c70..9dec910f9fc46 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts @@ -27,6 +27,7 @@ describe('PATCH comment', () => { }); it(`Patch a comment`, async () => { + const commentID = 'mock-comment-1'; const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, method: 'patch', @@ -36,7 +37,7 @@ describe('PATCH comment', () => { body: { type: CommentType.user, comment: 'Update my comment', - id: 'mock-comment-1', + id: commentID, version: 'WzEsMV0=', }, }); @@ -50,12 +51,14 @@ describe('PATCH comment', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1].comment).toEqual( - 'Update my comment' + const updatedComment = response.payload.comments.find( + (comment: { id: string }) => comment.id === commentID ); + expect(updatedComment.comment).toEqual('Update my comment'); }); it(`Patch an alert`, async () => { + const commentID = 'mock-comment-4'; const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, method: 'patch', @@ -66,7 +69,7 @@ describe('PATCH comment', () => { type: CommentType.alert, alertId: 'new-id', index: 'test-index', - id: 'mock-comment-4', + id: commentID, version: 'WzYsMV0=', }, }); @@ -80,9 +83,10 @@ describe('PATCH comment', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1].alertId).toEqual( - 'new-id' + const updatedComment = response.payload.comments.find( + (comment: { id: string }) => comment.id === commentID ); + expect(updatedComment.alertId).toEqual('new-id'); }); it(`it throws when missing attributes: type user`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index ac037ce3ead1f..e8b6f7bc957eb 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -12,12 +12,44 @@ import { identity } from 'fp-ts/lib/function'; import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; -import { CommentPatchRequestRt, CaseResponseRt, throwErrors } from '../../../../../common/api'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { SavedObjectsClientContract } from 'kibana/server'; +import { CommentableCase } from '../../../../common'; +import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/api'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError, flattenCaseSavedObject, decodeComment } from '../../utils'; +import { escapeHatch, wrapError, decodeCommentRequest } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CaseServiceSetup } from '../../../../services'; + +interface CombinedCaseParams { + service: CaseServiceSetup; + client: SavedObjectsClientContract; + caseID: string; + subCaseID?: string; +} + +async function getCommentableCase({ service, client, caseID, subCaseID }: CombinedCaseParams) { + if (subCaseID) { + const [caseInfo, subCase] = await Promise.all([ + service.getCase({ + client, + id: caseID, + }), + service.getSubCase({ + client, + id: subCaseID, + }), + ]); + return new CommentableCase({ collection: caseInfo, service, subCase, soClient: client }); + } else { + const caseInfo = await service.getCase({ + client, + id: caseID, + }); + return new CommentableCase({ collection: caseInfo, service, soClient: client }); + } +} export function initPatchCommentApi({ caseConfigureService, @@ -32,24 +64,30 @@ export function initPatchCommentApi({ params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), body: escapeHatch, }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; - const caseId = request.params.case_id; const query = pipe( CommentPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const { id: queryCommentId, version: queryCommentVersion, ...queryRestAttributes } = query; - decodeComment(queryRestAttributes); + decodeCommentRequest(queryRestAttributes); - const myCase = await caseService.getCase({ + const commentableCase = await getCommentableCase({ + service: caseService, client, - caseId, + caseID: request.params.case_id, + subCaseID: request.query?.subCaseID, }); const myComment = await caseService.getComment({ @@ -65,9 +103,13 @@ export function initPatchCommentApi({ throw Boom.badRequest(`You cannot change the type of the comment.`); } - const caseRef = myComment.references.find((c) => c.type === CASE_SAVED_OBJECT); - if (caseRef == null || (caseRef != null && caseRef.id !== caseId)) { - throw Boom.notFound(`This comment ${queryCommentId} does not exist in ${caseId}).`); + 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) { @@ -77,84 +119,46 @@ export function initPatchCommentApi({ } // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); + const userInfo: User = { + username, + full_name, + email, + }; + const updatedDate = new Date().toISOString(); - const [updatedComment, updatedCase] = await Promise.all([ - caseService.patchComment({ - client, - commentId: queryCommentId, - updatedAttributes: { - ...queryRestAttributes, - updated_at: updatedDate, - updated_by: { email, full_name, username }, - }, - version: queryCommentVersion, - }), - caseService.patchCase({ - client, - caseId, - updatedAttributes: { - updated_at: updatedDate, - updated_by: { username, full_name, email }, - }, - version: myCase.version, - }), - ]); - - const totalCommentsFindByCases = await caseService.getAllCaseComments({ - client, - caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, + const { + comment: updatedComment, + commentableCase: updatedCase, + } = await commentableCase.updateComment({ + updateRequest: query, + updatedAt: updatedDate, + user: userInfo, }); - const [comments] = await Promise.all([ - caseService.getAllCaseComments({ - client, - caseId: request.params.case_id, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindByCases.total, - }, - }), - userActionService.postUserActions({ - client, - actions: [ - buildCommentUserActionItem({ - action: 'update', - actionAt: updatedDate, - actionBy: { username, full_name, email }, - caseId: request.params.case_id, - 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) - ), - }), - ], - }), - ]); + await userActionService.postUserActions({ + client, + 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) + ), + }), + ], + }); return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase.attributes }, - version: updatedCase.version ?? myCase.version, - references: myCase.references, - }, - comments: comments.saved_objects, - }) - ), + body: await updatedCase.encode(), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index 0ab038a62ac77..fb51b8f76d0ef 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -299,21 +299,24 @@ describe('POST comment', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(response.payload.comments[response.payload.comments.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 761beb964823a..95b611950bd41 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -19,6 +19,11 @@ export function initPostCommentApi({ router }: RouteDeps) { params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), body: escapeHatch, }, }, @@ -28,12 +33,12 @@ export function initPostCommentApi({ router }: RouteDeps) { } const caseClient = context.case.getCaseClient(); - const caseId = request.params.case_id; + const caseId = request.query?.subCaseID ?? request.params.case_id; const comment = request.body as CommentRequest; try { return response.ok({ - body: await caseClient.addComment({ caseClient, caseId, comment }), + body: await caseClient.addComment({ caseId, comment }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts index 17972e129a825..33226d39a2595 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts @@ -40,7 +40,6 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.type, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 6925f116136b3..02d39465373f9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -56,7 +56,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout } // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const updateDate = new Date().toISOString(); @@ -73,7 +73,6 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.type, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index 0bcf2ac18740f..db3d5cd6a2e56 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -58,14 +58,13 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ); } // eslint-disable-next-line @typescript-eslint/naming-convention - const { email, full_name, username } = await caseService.getUser({ request, response }); + const { email, full_name, username } = await caseService.getUser({ request }); const creationDate = new Date().toISOString(); let mappings: ConnectorMappingsAttributes[] = []; try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: query.connector.id, connectorType: query.connector.type, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts index d588950bec9aa..a441a027769bf 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts @@ -52,12 +52,15 @@ describe('DELETE case', () => { }, }); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); @@ -71,12 +74,16 @@ describe('DELETE case', () => { }, }); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCaseComments, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCaseComments, + }); + + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); @@ -90,12 +97,16 @@ describe('DELETE case', () => { }, }); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCasesErrorTriggerData, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCasesErrorTriggerData, + }); + + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index c72dde9f77bf1..263b814df4146 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -7,10 +7,75 @@ import { schema } from '@kbn/config-schema'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { CaseType } from '../../../../common/api'; import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; import { CASES_URL } from '../../../../common/constants'; +import { CaseServiceSetup } from '../../../services'; + +async function unremovableCases({ + caseService, + client, + ids, + force, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + ids: string[]; + force: boolean | undefined; +}): Promise { + // if the force flag was included then we can skip checking whether the cases are collections and go ahead + // and delete them + if (force) { + return []; + } + + const cases = await caseService.getCases({ caseIds: ids, client }); + const parentCases = cases.saved_objects.filter( + /** + * getCases will return an array of saved_objects and some can be successful cases where as others + * might have failed to find the ID. If it fails to find it, it will set the error field but not + * the attributes so check that we didn't receive an error. + */ + (caseObj) => !caseObj.error && caseObj.attributes.type === CaseType.collection + ); + + return parentCases.map((parentCase) => parentCase.id); +} + +async function deleteSubCases({ + caseService, + client, + caseIds, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseIds: string[]; +}) { + const subCasesForCaseIds = await caseService.findSubCasesByCaseId({ client, ids: caseIds }); + + const subCaseIDs = subCasesForCaseIds.saved_objects.map((subCase) => subCase.id); + const commentsForSubCases = await caseService.getAllSubCaseComments({ + client, + 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) => + caseService.deleteComment({ client, commentId: commentSO.id }) + ) + ); + + await Promise.all( + subCasesForCaseIds.saved_objects.map((subCaseSO) => + caseService.deleteSubCase(client, subCaseSO.id) + ) + ); +} export function initDeleteCasesApi({ caseService, router, userActionService }: RouteDeps) { router.delete( @@ -19,17 +84,30 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R validate: { query: schema.object({ ids: schema.arrayOf(schema.string()), + force: schema.maybe(schema.boolean()), }), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; + const unremovable = await unremovableCases({ + caseService, + client, + ids: request.query.ids, + force: request.query.force, + }); + + if (unremovable.length > 0) { + return response.badRequest({ + body: `Case IDs: [${unremovable.join(' ,')}] are not removable`, + }); + } await Promise.all( request.query.ids.map((id) => caseService.deleteCase({ client, - caseId: id, + id, }) ) ); @@ -37,7 +115,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R request.query.ids.map((id) => caseService.getAllCaseComments({ client, - caseId: id, + id, }) ) ); @@ -56,8 +134,10 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R ) ); } + + await deleteSubCases({ caseService, client, caseIds: request.query.ids }); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); await userActionService.postUserActions({ @@ -68,7 +148,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: id, - fields: ['comment', 'description', 'status', 'tags', 'title'], + fields: ['comment', 'description', 'status', 'tags', 'title', 'sub_case'], }) ), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 2bfce8b908803..8ba83b42c06d7 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -11,40 +11,16 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { isEmpty } from 'lodash'; import { CasesFindResponseRt, CasesFindRequestRt, throwErrors, - CaseStatuses, caseStatuses, } from '../../../../common/api'; -import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; -import { RouteDeps, TotalCommentByCase } from '../types'; -import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { transformCases, wrapError, escapeHatch } from '../utils'; +import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; - -const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => - filters?.filter((i) => i !== '').join(` ${operator} `); - -const getStatusFilter = (status: CaseStatuses, appendFilter?: string) => - `${CASE_SAVED_OBJECT}.attributes.status: ${status}${ - !isEmpty(appendFilter) ? ` AND ${appendFilter}` : '' - }`; - -const buildFilter = ( - filters: string | string[] | undefined, - field: string, - operator: 'OR' | 'AND' -): string => - filters != null && filters.length > 0 - ? Array.isArray(filters) - ? // Be aware of the surrounding parenthesis (as string inside literal) around filters. - `(${filters - .map((filter) => `${CASE_SAVED_OBJECT}.attributes.${field}: ${filter}`) - ?.join(` ${operator} `)})` - : `${CASE_SAVED_OBJECT}.attributes.${field}: ${filters}` - : ''; +import { constructQueryOptions } from './helpers'; export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { router.get( @@ -62,79 +38,42 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: fold(throwErrors(Boom.badRequest), identity) ); - const { tags, reporters, status, ...query } = queryParams; - const tagsFilter = buildFilter(tags, 'tags', 'OR'); - const reportersFilters = buildFilter(reporters, 'created_by.username', 'OR'); - - const myFilters = combineFilters([tagsFilter, reportersFilters], 'AND'); - const filter = status != null ? getStatusFilter(status, myFilters) : myFilters; + const queryArgs = { + tags: queryParams.tags, + reporters: queryParams.reporters, + sortByField: queryParams.sortField, + status: queryParams.status, + caseType: queryParams.type, + }; - const args = queryParams - ? { - client, - options: { - ...query, - filter, - sortField: sortToSnake(query.sortField ?? ''), - }, - } - : { - client, - }; + const caseQueries = constructQueryOptions(queryArgs); - const statusArgs = caseStatuses.map((caseStatus) => ({ + const cases = await caseService.findCasesGroupedByID({ client, - options: { - fields: [], - page: 1, - perPage: 1, - filter: getStatusFilter(caseStatus, myFilters), - }, - })); - - const [cases, openCases, inProgressCases, closedCases] = await Promise.all([ - caseService.findCases(args), - ...statusArgs.map((arg) => caseService.findCases(arg)), - ]); + caseOptions: { ...queryParams, ...caseQueries.case }, + subCaseOptions: caseQueries.subCase, + }); - const totalCommentsFindByCases = await Promise.all( - cases.saved_objects.map((c) => - caseService.getAllCaseComments({ + const [openCases, inProgressCases, closedCases] = await Promise.all([ + ...caseStatuses.map((status) => { + const statusQuery = constructQueryOptions({ ...queryArgs, status }); + return caseService.findCaseStatusStats({ client, - caseId: c.id, - options: { - fields: [], - page: 1, - perPage: 1, - }, - }) - ) - ); - - const totalCommentsByCases = totalCommentsFindByCases.reduce( - (acc, itemFind) => { - if (itemFind.saved_objects.length > 0) { - const caseId = - itemFind.saved_objects[0].references.find((r) => r.type === CASE_SAVED_OBJECT) - ?.id ?? null; - if (caseId != null) { - return [...acc, { caseId, totalComments: itemFind.total }]; - } - } - return [...acc]; - }, - [] - ); + caseOptions: statusQuery.case, + subCaseOptions: statusQuery.subCase, + }); + }), + ]); return response.ok({ body: CasesFindResponseRt.encode( - transformCases( - cases, - openCases.total ?? 0, - inProgressCases.total ?? 0, - closedCases.total ?? 0, - totalCommentsByCases - ) + transformCases({ + ...cases, + countOpenCases: openCases, + countInProgressCases: inProgressCases, + countClosedCases: closedCases, + total: cases.casesMap.size, + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index 55377d93e528d..a3311796fa5cd 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -20,22 +20,22 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro case_id: schema.string(), }), query: schema.object({ - includeComments: schema.string({ defaultValue: 'true' }), + includeComments: schema.boolean({ defaultValue: true }), + includeSubCaseComments: schema.maybe(schema.boolean({ defaultValue: false })), }), }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } - const caseClient = context.case.getCaseClient(); - const includeComments = JSON.parse(request.query.includeComments); const id = request.params.case_id; try { return response.ok({ - body: await caseClient.get({ id, includeComments }), + body: await caseClient.get({ + id, + includeComments: request.query.includeComments, + includeSubCaseComments: request.query.includeSubCaseComments, + }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index d888eb21a4946..a1a7f4f9da8f5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -12,12 +12,174 @@ import { SavedObjectsFindResponse } from 'kibana/server'; import { CaseConnector, ESCaseConnector, - ESCaseAttributes, - ESCasePatchRequest, ESCasesConfigureAttributes, ConnectorTypes, + CaseStatuses, + CaseType, + SavedObjectFindOptions, } from '../../../../common/api'; import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { sortToSnake } from '../utils'; +import { combineFilters } from '../../../common'; + +export const addStatusFilter = ({ + status, + appendFilter, + type = CASE_SAVED_OBJECT, +}: { + status: CaseStatuses | undefined; + appendFilter?: string; + type?: string; +}) => { + const filters: string[] = []; + if (status) { + filters.push(`${type}.attributes.status: ${status}`); + } + + if (appendFilter) { + filters.push(appendFilter); + } + return combineFilters(filters, 'AND'); +}; + +export const buildFilter = ({ + filters, + field, + operator, + type = CASE_SAVED_OBJECT, +}: { + filters: string | string[] | undefined; + field: string; + operator: 'OR' | 'AND'; + type?: string; +}): string => { + // if it is an empty string, empty array of strings, or undefined just return + if (!filters || filters.length <= 0) { + return ''; + } + + const arrayFilters = !Array.isArray(filters) ? [filters] : filters; + + return combineFilters( + arrayFilters.map((filter) => `${type}.attributes.${field}: ${filter}`), + operator + ); +}; + +/** + * Constructs the filters used for finding cases and sub cases. + * There are a few scenarios that this function tries to handle when constructing the filters used for finding cases + * and sub cases. + * + * Scenario 1: + * Type == Individual + * If the API request specifies that it wants only individual cases (aka not collections) then we need to add that + * specific filter when call the saved objects find api. This will filter out any collection cases. + * + * Scenario 2: + * Type == collection + * If the API request specifies that it only wants collection cases (cases that have sub cases) then we need to add + * the filter for collections AND we need to ignore any status filter for the case find call. This is because a + * collection's status is no longer relevant when it has sub cases. The user cannot change the status for a collection + * only for its sub cases. The status filter will be applied to the find request when looking for sub cases. + * + * Scenario 3: + * No Type is specified + * If the API request does not want to filter on type but instead get both collections and regular individual cases then + * we need to find all cases that match the other filter criteria and sub cases. To do this we construct the following query: + * + * ((status == some_status and type === individual) or type == collection) and (tags == blah) and (reporter == yo) + * This forces us to honor the status request for individual cases but gets us ALL collection cases that match the other + * filter criteria. When we search for sub cases we will use that status filter in that find call as well. + */ +export const constructQueryOptions = ({ + tags, + reporters, + status, + sortByField, + caseType, +}: { + tags?: string | string[]; + reporters?: string | string[]; + status?: CaseStatuses; + sortByField?: string; + caseType?: CaseType; +}): { case: SavedObjectFindOptions; subCase?: SavedObjectFindOptions } => { + const tagsFilter = buildFilter({ filters: tags, field: 'tags', operator: 'OR' }); + const reportersFilter = buildFilter({ + filters: reporters, + field: 'created_by.username', + operator: 'OR', + }); + const sortField = sortToSnake(sortByField); + + switch (caseType) { + case CaseType.individual: { + // The cases filter will result in this structure "status === oh and (type === individual) and (tags === blah) and (reporter === yo)" + // The subCase filter will be undefined because we don't need to find sub cases if type === individual + + // We do not want to support multiple type's being used, so force it to be a single filter value + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const caseFilters = addStatusFilter({ + status, + appendFilter: combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'), + }); + return { + case: { + filter: caseFilters, + sortField, + }, + }; + } + case CaseType.collection: { + // The cases filter will result in this structure "(type == parent) and (tags == blah) and (reporter == yo)" + // The sub case filter will use the query.status if it exists + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.collection}`; + const caseFilters = combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'); + + return { + case: { + filter: caseFilters, + sortField, + }, + subCase: { + filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), + sortField, + }, + }; + } + default: { + /** + * In this scenario no type filter was sent, so we want to honor the status filter if one exists. + * To construct the filter and honor the status portion we need to find all individual cases that + * have that particular status. We also need to find cases that have sub cases but we want to ignore the + * case collection's status because it is not relevant. We only care about the status of the sub cases if the + * case is a collection. + * + * The cases filter will result in this structure "((status == open and type === individual) or type == parent) and (tags == blah) and (reporter == yo)" + * The sub case filter will use the query.status if it exists + */ + const typeIndividual = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const typeParent = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.collection}`; + + const statusFilter = combineFilters([addStatusFilter({ status }), typeIndividual], 'AND'); + const statusAndType = combineFilters([statusFilter, typeParent], 'OR'); + const caseFilters = combineFilters([statusAndType, tagsFilter, reportersFilter], 'AND'); + + return { + case: { + filter: caseFilters, + sortField, + }, + subCase: { + filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), + sortField, + }, + }; + } + } +}; interface CompareArrays { addedItems: string[]; @@ -66,10 +228,16 @@ export const isTwoArraysDifference = ( return null; }; +interface CaseWithIDVersion { + id: string; + version: string; + [key: string]: unknown; +} + export const getCaseToUpdate = ( - currentCase: ESCaseAttributes, - queryCase: ESCasePatchRequest -): ESCasePatchRequest => + currentCase: unknown, + queryCase: CaseWithIDVersion +): CaseWithIDVersion => Object.entries(queryCase).reduce( (acc, [key, value]) => { const currentValue = get(currentCase, key); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 6d1134b15b65e..e50d14e5c66c4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -17,7 +17,7 @@ import { } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -import { ConnectorTypes, CaseStatuses } from '../../../../common/api'; +import { CaseStatuses } from '../../../../common/api'; describe('PATCH cases', () => { let routeHandler: RequestHandler; @@ -52,34 +52,53 @@ describe('PATCH cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.closed, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + expect(response.payload).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "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", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); it(`Open a case`, async () => { @@ -106,34 +125,53 @@ describe('PATCH cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: '123', - name: 'My connector', - type: '.jira', - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - id: 'mock-id-4', - external_service: null, - status: CaseStatuses.open, - tags: ['LOLBins'], - title: 'Another bad one', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + expect(response.payload).toMatchInlineSnapshot(` + Array [ + 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-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); it(`Change case to in-progress`, async () => { @@ -159,34 +197,49 @@ describe('PATCH cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses['in-progress'], - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + expect(response.payload).toMatchInlineSnapshot(` + 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", + "settings": Object { + "syncAlerts": true, + }, + "status": "in-progress", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); it(`Patches a case without a connector.id`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index a320fafe4e5b4..67d4d21a57634 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -28,7 +28,7 @@ export function initPatchCasesApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.update({ caseClient, cases }), + body: await caseClient.update(cases), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 292e2c6775a80..53829157c5b04 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -189,35 +189,42 @@ describe('POST cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual({ - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - id: 'mock-it', - status: CaseStatuses.open, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(response.payload).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": null, + "full_name": null, + "username": null, + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 09f746a62d58a..349ed6c3e5af9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -28,7 +28,7 @@ export function initPostCaseApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.create({ theCase }), + body: await caseClient.create({ ...theCase }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts index 49801ea4e2f3e..bf398d1ffcf40 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts @@ -131,7 +131,10 @@ describe('Push case', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(caseClient.getAlerts).toHaveBeenCalledWith({ ids: ['test-id'] }); + expect(caseClient.getAlerts).toHaveBeenCalledWith({ + ids: ['test-id'], + indices: new Set(['test-index']), + }); }); it(`Calls execute with correct arguments`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 6d670c38bbf85..c1f0a2cb59cb1 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -45,7 +45,6 @@ export function initPushCaseApi({ router }: RouteDeps) { return response.ok({ body: await caseClient.push({ - caseClient, actionsClient, caseId: params.case_id, connectorId: params.connector_id, diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts index 9644162629f24..1c399a415e470 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts @@ -16,6 +16,7 @@ import { } from '../../__fixtures__'; import { initGetCasesStatusApi } from './get_status'; import { CASE_STATUS_URL } from '../../../../../common/constants'; +import { CaseType } from '../../../../../common/api'; describe('GET status', () => { let routeHandler: RequestHandler; @@ -24,6 +25,7 @@ describe('GET status', () => { page: 1, perPage: 1, type: 'cases', + sortField: 'created_at', }; beforeAll(async () => { @@ -45,17 +47,17 @@ describe('GET status', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { ...findArgs, - filter: 'cases.attributes.status: open', + filter: `((cases.attributes.status: open AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { ...findArgs, - filter: 'cases.attributes.status: in-progress', + filter: `((cases.attributes.status: in-progress AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { ...findArgs, - filter: 'cases.attributes.status: closed', + filter: `((cases.attributes.status: closed AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(response.payload).toEqual({ diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts index 8300e38a56896..f3cd0e2bdda5c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -9,8 +9,8 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { CASE_STATUS_URL } from '../../../../../common/constants'; +import { constructQueryOptions } from '../helpers'; export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { router.get( @@ -21,25 +21,23 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const args = caseStatuses.map((status) => ({ - client, - options: { - fields: [], - page: 1, - perPage: 1, - filter: `${CASE_SAVED_OBJECT}.attributes.status: ${status}`, - }, - })); - const [openCases, inProgressCases, closesCases] = await Promise.all( - args.map((arg) => caseService.findCases(arg)) - ); + const [openCases, inProgressCases, closedCases] = await Promise.all([ + ...caseStatuses.map((status) => { + const statusQuery = constructQueryOptions({ status }); + return caseService.findCaseStatusStats({ + client, + caseOptions: statusQuery.case, + subCaseOptions: statusQuery.subCase, + }); + }), + ]); return response.ok({ body: CasesStatusResponseRt.encode({ - count_open_cases: openCases.total, - count_in_progress_cases: inProgressCases.total, - count_closed_cases: closesCases.total, + count_open_cases: openCases, + count_in_progress_cases: inProgressCases, + count_closed_cases: closedCases, }), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts new file mode 100644 index 0000000000000..db701dd0fc82b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts @@ -0,0 +1,87 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { buildCaseUserActionItem } from '../../../../services/user_actions/helpers'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; + +export function initDeleteSubCasesApi({ caseService, router, userActionService }: RouteDeps) { + router.delete( + { + path: SUB_CASES_PATCH_DEL_URL, + validate: { + query: schema.object({ + ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + + const [comments, subCases] = await Promise.all([ + caseService.getAllSubCaseComments({ client, id: request.query.ids }), + caseService.getSubCases({ client, ids: request.query.ids }), + ]); + + const subCaseErrors = subCases.saved_objects.filter( + (subCase) => subCase.error !== undefined + ); + + if (subCaseErrors.length > 0) { + throw Boom.notFound( + `These sub cases ${subCaseErrors + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + const subCaseIDToParentID = subCases.saved_objects.reduce((acc, subCase) => { + const parentID = subCase.references.find((ref) => ref.type === CASE_SAVED_OBJECT); + acc.set(subCase.id, parentID?.id); + return acc; + }, new Map()); + + await Promise.all( + comments.saved_objects.map((comment) => + caseService.deleteComment({ client, commentId: comment.id }) + ) + ); + + await Promise.all(request.query.ids.map((id) => caseService.deleteSubCase(client, id))); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = await caseService.getUser({ request }); + const deleteDate = new Date().toISOString(); + + await userActionService.postUserActions({ + client, + actions: request.query.ids.map((id) => + buildCaseUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + // if for some reason the sub case didn't have a reference to its parent, we'll still log a user action + // but we won't have the case ID + caseId: subCaseIDToParentID.get(id) ?? '', + subCaseId: id, + fields: ['sub_case', 'comment', 'status'], + }) + ), + }); + + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts new file mode 100644 index 0000000000000..98052ccaeaba8 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts @@ -0,0 +1,95 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import Boom from '@hapi/boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + caseStatuses, + SubCasesFindRequestRt, + SubCasesFindResponseRt, + throwErrors, +} from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { escapeHatch, transformSubCases, wrapError } from '../../utils'; +import { SUB_CASES_URL } from '../../../../../common/constants'; +import { constructQueryOptions } from '../helpers'; +import { defaultPage, defaultPerPage } from '../..'; + +export function initFindSubCasesApi({ caseService, router }: RouteDeps) { + router.get( + { + path: `${SUB_CASES_URL}/_find`, + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const queryParams = pipe( + SubCasesFindRequestRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const ids = [request.params.case_id]; + const { subCase: subCaseQueryOptions } = constructQueryOptions({ + status: queryParams.status, + sortByField: queryParams.sortField, + }); + + const subCases = await caseService.findSubCasesGroupByCase({ + client, + ids, + options: { + sortField: 'created_at', + page: defaultPage, + perPage: defaultPerPage, + ...queryParams, + ...subCaseQueryOptions, + }, + }); + + const [open, inProgress, closed] = await Promise.all([ + ...caseStatuses.map((status) => { + const { subCase: statusQueryOptions } = constructQueryOptions({ + status, + sortByField: queryParams.sortField, + }); + return caseService.findSubCaseStatusStats({ + client, + options: statusQueryOptions ?? {}, + ids, + }); + }), + ]); + + return response.ok({ + body: SubCasesFindResponseRt.encode( + transformSubCases({ + ...subCases, + open, + inProgress, + closed, + // there should only be one entry in the map for the requested case ID + total: subCases.subCasesMap.get(request.params.case_id)?.length ?? 0, + }) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts new file mode 100644 index 0000000000000..b6d9a7345dbdd --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts @@ -0,0 +1,77 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { SubCaseResponseRt } from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { flattenSubCaseSavedObject, wrapError } from '../../utils'; +import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; +import { countAlertsForID } from '../../../../common'; + +export function initGetSubCaseApi({ caseService, router }: RouteDeps) { + router.get( + { + path: SUB_CASE_DETAILS_URL, + validate: { + params: schema.object({ + case_id: schema.string(), + sub_case_id: schema.string(), + }), + query: schema.object({ + includeComments: schema.boolean({ defaultValue: true }), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const includeComments = request.query.includeComments; + + const subCase = await caseService.getSubCase({ + client, + id: request.params.sub_case_id, + }); + + if (!includeComments) { + return response.ok({ + body: SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + }) + ), + }); + } + + const theComments = await caseService.getAllSubCaseComments({ + client, + id: request.params.sub_case_id, + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, + }); + + return response.ok({ + body: SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + totalAlerts: countAlertsForID({ + comments: theComments, + id: request.params.sub_case_id, + }), + }) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts new file mode 100644 index 0000000000000..ca5cd657a39f3 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -0,0 +1,418 @@ +/* + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { + SavedObjectsClientContract, + KibanaRequest, + SavedObject, + SavedObjectsFindResponse, +} from 'kibana/server'; + +import { CaseClient } from '../../../../client'; +import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../../../services'; +import { + CaseStatuses, + SubCasesPatchRequest, + SubCasesPatchRequestRt, + CommentType, + excess, + throwErrors, + SubCasesResponse, + SubCasePatchRequest, + SubCaseAttributes, + ESCaseAttributes, + SubCaseResponse, + SubCasesResponseRt, + User, + CommentAttributes, +} from '../../../../../common/api'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; +import { RouteDeps } from '../../types'; +import { + AlertInfo, + escapeHatch, + flattenSubCaseSavedObject, + isCommentRequestTypeAlertOrGenAlert, + wrapError, +} from '../../utils'; +import { getCaseToUpdate } from '../helpers'; +import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; +import { addAlertInfoToStatusMap } from '../../../../common'; + +interface UpdateArgs { + client: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + request: KibanaRequest; + caseClient: CaseClient; + subCases: SubCasesPatchRequest; +} + +function checkNonExistingOrConflict( + toUpdate: SubCasePatchRequest[], + fromStorage: Map> +) { + const nonExistingSubCases: SubCasePatchRequest[] = []; + const conflictedSubCases: SubCasePatchRequest[] = []; + for (const subCaseToUpdate of toUpdate) { + const bulkEntry = fromStorage.get(subCaseToUpdate.id); + + if (bulkEntry && bulkEntry.error) { + nonExistingSubCases.push(subCaseToUpdate); + } + + if (!bulkEntry || bulkEntry.version !== subCaseToUpdate.version) { + conflictedSubCases.push(subCaseToUpdate); + } + } + + if (nonExistingSubCases.length > 0) { + throw Boom.notFound( + `These sub cases ${nonExistingSubCases + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + if (conflictedSubCases.length > 0) { + throw Boom.conflict( + `These sub cases ${conflictedSubCases + .map((c) => c.id) + .join(', ')} has been updated. Please refresh before saving additional updates.` + ); + } +} + +interface GetParentIDsResult { + ids: string[]; + parentIDToSubID: Map; +} + +function getParentIDs({ + subCasesMap, + subCaseIDs, +}: { + subCasesMap: Map>; + subCaseIDs: string[]; +}): GetParentIDsResult { + return subCaseIDs.reduce( + (acc, id) => { + const subCase = subCasesMap.get(id); + if (subCase && subCase.references.length > 0) { + const parentID = subCase.references[0].id; + acc.ids.push(parentID); + let subIDs = acc.parentIDToSubID.get(parentID); + if (subIDs === undefined) { + subIDs = []; + } + subIDs.push(id); + acc.parentIDToSubID.set(parentID, subIDs); + } + return acc; + }, + { ids: [], parentIDToSubID: new Map() } + ); +} + +async function getParentCases({ + caseService, + client, + subCaseIDs, + subCasesMap, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + subCaseIDs: string[]; + subCasesMap: Map>; +}): Promise>> { + const parentIDInfo = getParentIDs({ subCaseIDs, subCasesMap }); + + const parentCases = await caseService.getCases({ + client, + caseIds: parentIDInfo.ids, + }); + + const parentCaseErrors = parentCases.saved_objects.filter((so) => so.error !== undefined); + + if (parentCaseErrors.length > 0) { + throw Boom.badRequest( + `Unable to find parent cases: ${parentCaseErrors + .map((c) => c.id) + .join(', ')} for sub cases: ${subCaseIDs.join(', ')}` + ); + } + + return parentCases.saved_objects.reduce((acc, so) => { + const subCaseIDsWithParent = parentIDInfo.parentIDToSubID.get(so.id); + subCaseIDsWithParent?.forEach((subCaseID) => { + acc.set(subCaseID, so); + }); + return acc; + }, new Map>()); +} + +function getValidUpdateRequests( + toUpdate: SubCasePatchRequest[], + subCasesMap: Map> +): SubCasePatchRequest[] { + const validatedSubCaseAttributes: SubCasePatchRequest[] = toUpdate.map((updateCase) => { + const currentCase = subCasesMap.get(updateCase.id); + return currentCase != null + ? getCaseToUpdate(currentCase.attributes, { + ...updateCase, + }) + : { id: updateCase.id, version: updateCase.version }; + }); + + return validatedSubCaseAttributes.filter((updateCase: SubCasePatchRequest) => { + const { id, version, ...updateCaseAttributes } = updateCase; + return Object.keys(updateCaseAttributes).length > 0; + }); +} + +/** + * Get the id from a reference in a comment for a sub case + */ +function getID(comment: SavedObject): string | undefined { + return comment.references.find((ref) => ref.type === SUB_CASE_SAVED_OBJECT)?.id; +} + +/** + * Get all the alert comments for a set of sub cases + */ +async function getAlertComments({ + subCasesToSync, + caseService, + client, +}: { + subCasesToSync: SubCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const ids = subCasesToSync.map((subCase) => subCase.id); + return caseService.getAllSubCaseComments({ + client, + id: ids, + options: { + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + }, + }); +} + +/** + * Updates the status of alerts for the specified sub cases. + */ +async function updateAlerts({ + subCasesToSync, + caseService, + client, + caseClient, +}: { + subCasesToSync: SubCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseClient: CaseClient; +}) { + const subCasesToSyncMap = subCasesToSync.reduce((acc, subCase) => { + acc.set(subCase.id, subCase); + return acc; + }, new Map()); + // get all the alerts for all sub cases that need to be synced + const totalAlerts = await getAlertComments({ caseService, client, subCasesToSync }); + // create a map of the status (open, closed, etc) to alert info that needs to be updated + const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { + const id = getID(alertComment); + const status = + id !== undefined + ? subCasesToSyncMap.get(id)?.status ?? CaseStatuses.open + : CaseStatuses.open; + + addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); + } + return acc; + }, new Map()); + + // This does at most 3 calls to Elasticsearch to update the status of the alerts to either open, closed, or in-progress + for (const [status, alertInfo] of alertsToUpdate.entries()) { + if (alertInfo.ids.length > 0 && alertInfo.indices.size > 0) { + caseClient.updateAlertsStatus({ + ids: alertInfo.ids, + status, + indices: alertInfo.indices, + }); + } + } +} + +async function update({ + client, + caseService, + userActionService, + request, + caseClient, + subCases, +}: UpdateArgs): Promise { + const query = pipe( + excess(SubCasesPatchRequestRt).decode(subCases), + fold(throwErrors(Boom.badRequest), identity) + ); + + const bulkSubCases = await caseService.getSubCases({ + client, + ids: query.subCases.map((q) => q.id), + }); + + const subCasesMap = bulkSubCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); + + checkNonExistingOrConflict(query.subCases, subCasesMap); + + const nonEmptySubCaseRequests = getValidUpdateRequests(query.subCases, subCasesMap); + + if (nonEmptySubCaseRequests.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } + + const subIDToParentCase = await getParentCases({ + client, + caseService, + subCaseIDs: nonEmptySubCaseRequests.map((subCase) => subCase.id), + subCasesMap, + }); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = await caseService.getUser({ request }); + const updatedAt = new Date().toISOString(); + const updatedCases = await caseService.patchSubCases({ + client, + subCases: nonEmptySubCaseRequests.map((thisCase) => { + const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; + let closedInfo: { closed_at: string | null; closed_by: User | null } = { + closed_at: null, + closed_by: null, + }; + + if ( + updateSubCaseAttributes.status && + updateSubCaseAttributes.status === CaseStatuses.closed + ) { + closedInfo = { + closed_at: updatedAt, + closed_by: { email, full_name, username }, + }; + } else if ( + updateSubCaseAttributes.status && + (updateSubCaseAttributes.status === CaseStatuses.open || + updateSubCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + subCaseId, + updatedAttributes: { + ...updateSubCaseAttributes, + ...closedInfo, + updated_at: updatedAt, + updated_by: { email, full_name, username }, + }, + version, + }; + }), + }); + + const subCasesToSyncAlertsFor = nonEmptySubCaseRequests.filter((subCaseToUpdate) => { + const storedSubCase = subCasesMap.get(subCaseToUpdate.id); + const parentCase = subIDToParentCase.get(subCaseToUpdate.id); + return ( + storedSubCase !== undefined && + subCaseToUpdate.status !== undefined && + storedSubCase.attributes.status !== subCaseToUpdate.status && + parentCase?.attributes.settings.syncAlerts + ); + }); + + await updateAlerts({ + caseService, + client, + caseClient, + subCasesToSync: subCasesToSyncAlertsFor, + }); + + const returnUpdatedSubCases = updatedCases.saved_objects.reduce( + (acc, updatedSO) => { + const originalSubCase = subCasesMap.get(updatedSO.id); + if (originalSubCase) { + acc.push( + flattenSubCaseSavedObject({ + savedObject: { + ...originalSubCase, + ...updatedSO, + attributes: { ...originalSubCase.attributes, ...updatedSO.attributes }, + references: originalSubCase.references, + version: updatedSO.version ?? originalSubCase.version, + }, + }) + ); + } + return acc; + }, + [] + ); + + await userActionService.postUserActions({ + client, + actions: buildSubCaseUserActions({ + originalSubCases: bulkSubCases.saved_objects, + updatedSubCases: updatedCases.saved_objects, + actionDate: updatedAt, + actionBy: { email, full_name, username }, + }), + }); + + return SubCasesResponseRt.encode(returnUpdatedSubCases); +} + +export function initPatchSubCasesApi({ router, caseService, userActionService }: RouteDeps) { + router.patch( + { + path: SUB_CASES_PATCH_DEL_URL, + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + const caseClient = context.case.getCaseClient(); + const subCases = request.body as SubCasesPatchRequest; + + try { + return response.ok({ + body: await update({ + request, + subCases, + caseClient, + client: context.core.savedObjects.client, + caseService, + userActionService, + }), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 00660e08bbd83..f2fd986dd8a3a 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -30,6 +30,19 @@ import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; import { RouteDeps } from './types'; +import { initGetSubCaseApi } from './cases/sub_case/get_sub_case'; +import { initPatchSubCasesApi } from './cases/sub_case/patch_sub_cases'; +import { initFindSubCasesApi } from './cases/sub_case/find_sub_cases'; +import { initDeleteSubCasesApi } from './cases/sub_case/delete_sub_cases'; + +/** + * Default page number when interacting with the saved objects API. + */ +export const defaultPage = 1; +/** + * Default number of results when interacting with the saved objects API. + */ +export const defaultPerPage = 20; export function initCaseApi(deps: RouteDeps) { // Cases @@ -40,6 +53,11 @@ export function initCaseApi(deps: RouteDeps) { initPostCaseApi(deps); initPushCaseApi(deps); initGetAllUserActionsApi(deps); + // Sub cases + initGetSubCaseApi(deps); + initPatchSubCasesApi(deps); + initFindSubCasesApi(deps); + initDeleteSubCasesApi(deps); // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 163116e1316f1..1efec927efb62 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -10,7 +10,6 @@ import { transformNewComment, wrapError, transformCases, - flattenCaseSavedObjects, flattenCaseSavedObject, flattenCommentSavedObjects, transformComments, @@ -24,7 +23,14 @@ import { mockCaseComments, mockCaseNoConnectorId, } from './__fixtures__/mock_saved_objects'; -import { ConnectorTypes, ESCaseConnector, CommentType, CaseStatuses } from '../../../common/api'; +import { + ConnectorTypes, + ESCaseConnector, + CommentType, + AssociationType, + CaseType, + CaseResponse, +} from '../../../common/api'; describe('Utils', () => { describe('transformNewCase', () => { @@ -40,7 +46,7 @@ describe('Utils', () => { }; it('transform correctly', () => { const myCase = { - newCase, + newCase: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', email: 'elastic@elastic.co', @@ -50,46 +56,112 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + 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, + "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: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', }; const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: undefined, full_name: undefined, username: undefined }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + 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, + "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: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', email: null, @@ -99,18 +171,51 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: null, full_name: null, username: null }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + 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, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); }); }); @@ -123,19 +228,27 @@ describe('Utils', () => { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic', + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + 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', () => { @@ -143,20 +256,28 @@ describe('Utils', () => { comment: 'A comment', type: CommentType.user as const, createdDate: '2020-04-09T09:43:51.778Z', + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: undefined, full_name: undefined, username: undefined }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + 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', () => { @@ -167,20 +288,28 @@ describe('Utils', () => { email: null, full_name: null, username: null, + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: null, full_name: null, username: null }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + 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, + } + `); }); }); @@ -232,173 +361,200 @@ describe('Utils', () => { describe('transformCases', () => { it('transforms correctly', () => { - const extraCaseData = [ - { caseId: mockCases[0].id, totalComments: 2 }, - { caseId: mockCases[1].id, totalComments: 2 }, - { caseId: mockCases[2].id, totalComments: 2 }, - { caseId: mockCases[3].id, totalComments: 2 }, - ]; - - const res = transformCases( - { - saved_objects: mockCases.map((obj) => ({ ...obj, score: 1 })), - total: mockCases.length, - per_page: 10, - page: 1, - }, - 2, - 2, - 2, - extraCaseData + const casesMap = new Map( + mockCases.map((obj) => { + return [obj.id, flattenCaseSavedObject({ savedObject: obj, totalComment: 2 })]; + }) ); - expect(res).toEqual({ + const res = transformCases({ + casesMap, + countOpenCases: 2, + countInProgressCases: 2, + countClosedCases: 2, page: 1, - per_page: 10, - total: mockCases.length, - cases: flattenCaseSavedObjects( - mockCases.map((obj) => ({ ...obj, score: 1 })), - extraCaseData - ), - count_open_cases: 2, - count_closed_cases: 2, - count_in_progress_cases: 2, + perPage: 10, + total: casesMap.size, }); - }); - }); - - describe('flattenCaseSavedObjects', () => { - it('flattens correctly', () => { - const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 2 }]; - - const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - - expect(res).toEqual([ - { - id: 'mock-id-1', - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 2, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); - }); - - it('it handles total comments correctly when caseId is not in extraCaseData', () => { - const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }]; - const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - - expect(res).toEqual([ - { - id: 'mock-id-1', - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 0, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); - }); - - it('inserts missing connector', () => { - const extraCaseData = [ - { - caseId: mockCaseNoConnectorId.id, - totalComment: 0, - }, - ]; - - // @ts-ignore this is to update old case saved objects to include connector - const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData); - - expect(res).toEqual([ - { - id: mockCaseNoConnectorId.id, - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 0, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); + 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", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "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", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "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", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "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", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "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, + } + `); }); }); @@ -410,17 +566,51 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: myCase.version, - comments: [], - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + 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", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "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', () => { @@ -431,17 +621,51 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: '0', - comments: [], - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + 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", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "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', () => { @@ -453,17 +677,73 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: myCase.version, - comments: flattenCommentSavedObjects(comments), - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + 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", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "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', () => { @@ -477,40 +757,46 @@ describe('Utils', () => { ...extraCaseData, }); - expect(res).toEqual({ - id: mockCaseNoConnectorId.id, - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 2, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }); + 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", + "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=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index e2751c05d880a..bc82f656f477b 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -18,7 +18,6 @@ import { } from 'kibana/server'; import { - CasePostRequest, CaseResponse, CasesFindResponse, CommentResponse, @@ -28,17 +27,41 @@ import { ESCaseAttributes, CommentRequest, ContextTypeUserRt, - ContextTypeAlertRt, CommentRequestUserType, CommentRequestAlertType, CommentType, excess, throwErrors, CaseStatuses, + CaseClientPostRequest, + AssociationType, + SubCaseAttributes, + SubCaseResponse, + SubCasesFindResponse, + User, + AlertCommentRequestRt, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; -import { SortFieldCase, TotalCommentByCase } from './types'; +import { SortFieldCase } from './types'; + +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, @@ -53,7 +76,7 @@ export const transformNewCase = ({ createdDate: string; email?: string | null; full_name?: string | null; - newCase: CasePostRequest; + newCase: CaseClientPostRequest; username?: string | null; }): ESCaseAttributes => ({ ...newCase, @@ -69,28 +92,93 @@ export const transformNewCase = ({ }); 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 []; +}; + +/** + * This structure holds the alert IDs and indices found from multiple alert comments + */ +export interface AlertInfo { + ids: string[]; + indices: Set; +} + +const accumulateIndicesAndIDs = (comment: CommentAttributes, acc: AlertInfo): AlertInfo => { + if (isCommentRequestTypeAlertOrGenAlert(comment)) { + acc.ids.push(...getAlertIds(comment)); + acc.indices.add(comment.index); + } + return acc; +}; + +/** + * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alerts. + */ +export const getAlertIndicesAndIDs = (comments: CommentAttributes[] | undefined): AlertInfo => { + if (comments === undefined) { + return { ids: [], indices: new Set() }; + } + + return comments.reduce( + (acc: AlertInfo, comment) => { + return accumulateIndicesAndIDs(comment, acc); + }, + { ids: [], indices: new Set() } + ); +}; + +/** + * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alert saved objects. + */ +export const getAlertIndicesAndIDsFromSO = ( + comments: SavedObjectsFindResponse | undefined +): AlertInfo => { + if (comments === undefined) { + return { ids: [], indices: new Set() }; + } + + return comments.saved_objects.reduce( + (acc: AlertInfo, comment) => { + return accumulateIndicesAndIDs(comment.attributes, acc); + }, + { ids: [], indices: new Set() } + ); +}; + export const transformNewComment = ({ + associationType, createdDate, email, // eslint-disable-next-line @typescript-eslint/naming-convention full_name, username, ...comment -}: NewCommentArgs): CommentAttributes => ({ - ...comment, - created_at: createdDate, - created_by: { email, full_name, username }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, -}); +}: 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, + }; +}; export function wrapError(error: any): CustomHttpResponseOptions { const options = { statusCode: error.statusCode ?? 500 }; @@ -102,52 +190,99 @@ export function wrapError(error: any): CustomHttpResponseOptions }; } -export const transformCases = ( - cases: SavedObjectsFindResponse, - countOpenCases: number, - countInProgressCases: number, - countClosedCases: number, - totalCommentByCase: TotalCommentByCase[] -): CasesFindResponse => ({ - page: cases.page, - per_page: cases.per_page, - total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), +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 flattenCaseSavedObjects = ( - savedObjects: Array>, - totalCommentByCase: TotalCommentByCase[] -): CaseResponse[] => - savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { - return [ - ...acc, - flattenCaseSavedObject({ - savedObject, - totalComment: - totalCommentByCase.find((tc) => tc.caseId === savedObject.id)?.totalComments ?? 0, - }), - ]; - }, []); +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, }: { savedObject: SavedObject; comments?: Array>; totalComment?: number; + totalAlerts?: number; + subCases?: SubCaseResponse[]; }): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), totalComment, + totalAlerts, ...savedObject.attributes, connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), + subCases, +}); + +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 = ( @@ -174,7 +309,7 @@ export const flattenCommentSavedObject = ( ...savedObject.attributes, }); -export const sortToSnake = (sortField: string): SortFieldCase => { +export const sortToSnake = (sortField: string | undefined): SortFieldCase => { switch (sortField) { case 'status': return SortFieldCase.status; @@ -191,18 +326,41 @@ export const sortToSnake = (sortField: string): SortFieldCase => { export const escapeHatch = schema.object({}, { unknowns: 'allow' }); -export const isUserContext = (context: CommentRequest): context is CommentRequestUserType => { +/** + * 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; }; -export const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => { - return context.type === CommentType.alert; +/** + * 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 decodeComment = (comment: CommentRequest) => { - if (isUserContext(comment)) { +export const decodeCommentRequest = (comment: CommentRequest) => { + if (isCommentRequestTypeUser(comment)) { pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isAlertContext(comment)) { - pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isCommentRequestTypeAlertOrGenAlert(comment)) { + pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); } }; diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 64ee692d44798..5f413ea27c4a7 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -118,7 +118,10 @@ export const caseSavedObjectType: SavedObjectsType = { tags: { type: 'keyword', }, - + // collection or individual + type: { + type: 'keyword', + }, updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 336a1bdd172c6..9eabf744f2e13 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -16,6 +16,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { namespaceType: 'single', mappings: { properties: { + associationType: { + type: 'keyword', + }, comment: { type: 'text', }, diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts index 9599cbef9709d..91f104335df8b 100644 --- a/x-pack/plugins/case/server/saved_object_types/index.ts +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -6,6 +6,7 @@ */ export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; +export { subCaseSavedObjectType, SUB_CASE_SAVED_OBJECT } from './sub_case'; export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure'; export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions'; diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index fc7ffb34776ae..a0b22c49d0bc6 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -8,7 +8,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; -import { ConnectorTypes, CommentType } from '../../common/api'; +import { ConnectorTypes, CommentType, CaseType, AssociationType } from '../../common/api'; interface UnsanitizedCaseConnector { connector_id: string; @@ -49,6 +49,10 @@ interface SanitizedCaseSettings { }; } +interface SanitizedCaseType { + type: string; +} + export const caseMigrations = { '7.10.0': ( doc: SavedObjectUnsanitizedDoc @@ -83,6 +87,18 @@ export const caseMigrations = { references: doc.references || [], }; }, + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + type: CaseType.individual, + }, + references: doc.references || [], + }; + }, }; export const configureMigrations = { @@ -157,6 +173,10 @@ interface SanitizedComment { type: CommentType; } +interface SanitizedCommentAssociationType { + associationType: AssociationType; +} + export const commentsMigrations = { '7.11.0': ( doc: SavedObjectUnsanitizedDoc @@ -170,4 +190,16 @@ export const commentsMigrations = { references: doc.references || [], }; }, + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + associationType: AssociationType.case, + }, + references: doc.references || [], + }; + }, }; diff --git a/x-pack/plugins/case/server/saved_object_types/sub_case.ts b/x-pack/plugins/case/server/saved_object_types/sub_case.ts new file mode 100644 index 0000000000000..da89b19346e4e --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/sub_case.ts @@ -0,0 +1,71 @@ +/* + * 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 { SavedObjectsType } from 'src/core/server'; + +export const SUB_CASE_SAVED_OBJECT = 'cases-sub-case'; + +export const subCaseSavedObjectType: SavedObjectsType = { + name: SUB_CASE_SAVED_OBJECT, + hidden: false, + namespaceType: 'single', + mappings: { + properties: { + closed_at: { + type: 'date', + }, + closed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + status: { + type: 'keyword', + }, + updated_at: { + type: 'date', + }, + updated_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/scripts/mock/case/post_case.json b/x-pack/plugins/case/server/scripts/mock/case/post_case.json index 743fa396295ca..bed342dd69fe9 100644 --- a/x-pack/plugins/case/server/scripts/mock/case/post_case.json +++ b/x-pack/plugins/case/server/scripts/mock/case/post_case.json @@ -3,5 +3,14 @@ "title": "Bad meanie defacing data", "tags": [ "defacement" - ] + ], + "connector": { + "id": "none", + "name": "none", + "type": ".none", + "fields": null + }, + "settings": { + "syncAlerts": true + } } diff --git a/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json b/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json index 13efe436a640d..58fee92859bf9 100644 --- a/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json +++ b/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json @@ -3,5 +3,14 @@ "title": "Another bad dude", "tags": [ "phishing" - ] + ], + "connector": { + "id": "none", + "name": "none", + "type": ".none", + "fields": null + }, + "settings": { + "syncAlerts": true + } } diff --git a/x-pack/plugins/case/server/scripts/sub_cases/README.md b/x-pack/plugins/case/server/scripts/sub_cases/README.md new file mode 100644 index 0000000000000..92873b8f037f3 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/README.md @@ -0,0 +1,80 @@ +# Sub Cases Helper Script + +This script makes interacting with sub cases easier (creating, deleting, retrieving, etc). + +To run the script, first `cd x-pack/plugins/case/server/scripts` + +## Showing the help + +```bash +yarn test:sub-cases help +``` + +Sub command help + +```bash +yarn test:sub-cases help +``` + +## Generating alerts + +This will generate a new case and sub case if one does not exist and then attach a group +of alerts to it. + +```bash +yarn test:sub-cases alerts --ids id1 id2 id3 +``` + +## Deleting a collection + +This will delete a case that has sub cases. + +```bash +yarn test:sub-cases delete +``` + +## Find sub cases + +This will find sub cases attached to a collection. + +```bash +yarn test:sub-cases find [status] +``` + +Example: + +```bash +yarn test:sub-cases find 6c9e0490-64dc-11eb-92be-09d246866276 +``` + +Response: + +```bash +{ + "page": 1, + "per_page": 1, + "total": 1, + "subCases": [ + { + "id": "6dd6d2b0-64dc-11eb-92be-09d246866276", + "version": "WzUzNDMsMV0=", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "closed_at": null, + "closed_by": null, + "created_at": "2021-02-01T22:25:46.323Z", + "status": "open", + "updated_at": "2021-02-01T22:25:46.323Z", + "updated_by": { + "full_name": null, + "email": null, + "username": "elastic" + } + } + ], + "count_open_cases": 0, + "count_in_progress_cases": 0, + "count_closed_cases": 0 +} +``` diff --git a/x-pack/plugins/case/server/scripts/sub_cases/generator.js b/x-pack/plugins/case/server/scripts/sub_cases/generator.js new file mode 100644 index 0000000000000..0c5b8bfc8550b --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/generator.js @@ -0,0 +1,9 @@ +/* + * 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. + */ + +require('../../../../../../src/setup_node_env'); +require('./index'); diff --git a/x-pack/plugins/case/server/scripts/sub_cases/index.ts b/x-pack/plugins/case/server/scripts/sub_cases/index.ts new file mode 100644 index 0000000000000..2ea9718d18487 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -0,0 +1,217 @@ +/* + * 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. + */ +/* eslint-disable no-console */ +import yargs from 'yargs'; +import { KbnClient, ToolingLog } from '@kbn/dev-utils'; +import { + CaseResponse, + CaseType, + CollectionWithSubCaseResponse, + ConnectorTypes, +} from '../../../common/api'; +import { CommentType } from '../../../common/api/cases/comment'; +import { CASES_URL } from '../../../common/constants'; +import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common'; + +main(); + +function createClient(argv: any): KbnClient { + return new KbnClient({ + log: new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }), + url: argv.kibana, + }); +} + +async function handleFind(argv: any) { + const client = createClient(argv); + + try { + const res = await client.request({ + path: `${CASES_URL}/${argv.caseID}/sub_cases/_find`, + method: 'GET', + query: { + status: argv.status, + }, + }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (e) { + console.error(e); + throw e; + } +} + +async function handleDelete(argv: any) { + const client = createClient(argv); + + try { + await client.request({ + path: `${CASES_URL}?ids=["${argv.id}"]`, + method: 'DELETE', + query: { + force: true, + }, + }); + } catch (e) { + console.error(e); + throw e; + } +} + +async function handleGenGroupAlerts(argv: any) { + const client = createClient(argv); + + try { + const createdAction = await client.request({ + path: '/api/actions/action', + method: 'POST', + body: { + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }, + }); + + let caseID: string | undefined = argv.caseID as string | undefined; + + if (!caseID) { + console.log('Creating new case'); + const newCase = await client.request({ + path: CASES_URL, + method: 'POST', + body: { + description: 'This is a brand new case from generator script', + type: CaseType.collection, + title: 'Super Bad Security Issue', + tags: ['defacement'], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + }, + }); + caseID = newCase.data.id; + } + + console.log('Case id: ', caseID); + const executeResp = await client.request< + ActionTypeExecutorResult + >({ + path: `/api/actions/action/${createdAction.data.id}/_execute`, + method: 'POST', + body: { + params: { + subAction: 'addComment', + subActionParams: { + caseId: caseID, + comment: { + type: CommentType.generatedAlert, + alerts: argv.ids.map((id: string) => ({ _id: id })), + index: argv.signalsIndex, + }, + }, + }, + }, + }); + + if (executeResp.data.status !== 'ok') { + console.log( + 'Error received from actions api during execute: ', + JSON.stringify(executeResp.data, null, 2) + ); + process.exit(1); + } + + console.log('Execution response ', JSON.stringify(executeResp.data, null, 2)); + } catch (e) { + console.error(e); + throw e; + } +} + +async function main() { + // This returns before the async handlers do + // We need to convert this to commander instead I think + yargs(process.argv.slice(2)) + .help() + .options({ + kibana: { + alias: 'k', + describe: 'kibana url', + default: 'http://elastic:changeme@localhost:5601', + type: 'string', + }, + }) + .command({ + command: 'alerts', + aliases: ['gen', 'genAlerts'], + describe: 'generate a group of alerts', + builder: (args) => { + return args + .options({ + caseID: { + alias: 'c', + describe: 'case ID', + }, + ids: { + alias: 'a', + describe: 'alert ids', + type: 'array', + }, + signalsIndex: { + alias: 'i', + describe: 'siem signals index', + type: 'string', + default: '.siem-signals-default', + }, + }) + .demandOption(['ids']); + }, + handler: async (args) => { + return handleGenGroupAlerts(args); + }, + }) + .command({ + command: 'delete ', + describe: 'deletes a case', + builder: (args) => { + return args.positional('id', { + describe: 'case id', + type: 'string', + }); + }, + handler: async (args) => { + return handleDelete(args); + }, + }) + .command({ + command: 'find [status]', + describe: 'gets all sub cases', + builder: (args) => { + return args + .positional('caseID', { describe: 'case id', type: 'string' }) + .positional('status', { + describe: 'filter by status', + type: 'string', + }); + }, + handler: async (args) => { + return handleFind(args); + }, + }) + .demandCommand() + .parse(); + + console.log('completed'); +} diff --git a/x-pack/plugins/case/server/services/alerts/index.test.ts b/x-pack/plugins/case/server/services/alerts/index.test.ts index 2787d855a4c0d..35aa3ff80efc1 100644 --- a/x-pack/plugins/case/server/services/alerts/index.test.ts +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -6,20 +6,21 @@ */ import { KibanaRequest } from 'kibana/server'; -import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; import { CaseStatuses } from '../../../common/api'; import { AlertService, AlertServiceContract } from '.'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; describe('updateAlertsStatus', () => { - const esClientMock = elasticsearchServiceMock.createClusterClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); describe('happy path', () => { let alertService: AlertServiceContract; const args = { ids: ['alert-id-1'], - index: '.siem-signals', + indices: new Set(['.siem-signals']), request: {} as KibanaRequest, status: CaseStatuses.closed, + scopedClusterClient: esClient, }; beforeEach(async () => { @@ -28,30 +29,29 @@ describe('updateAlertsStatus', () => { }); test('it update the status of the alert correctly', async () => { - alertService.initialize(esClientMock); await alertService.updateAlertsStatus(args); - expect(esClientMock.asScoped().asCurrentUser.updateByQuery).toHaveBeenCalledWith({ + expect(esClient.updateByQuery).toHaveBeenCalledWith({ body: { query: { ids: { values: args.ids } }, script: { lang: 'painless', source: `ctx._source.signal.status = '${args.status}'` }, }, conflicts: 'abort', ignore_unavailable: true, - index: args.index, + index: [...args.indices], }); }); describe('unhappy path', () => { - test('it throws when service is already initialized', async () => { - alertService.initialize(esClientMock); - expect(() => { - alertService.initialize(esClientMock); - }).toThrow(); - }); - - test('it throws when service is not initialized and try to update the status', async () => { - await expect(alertService.updateAlertsStatus(args)).rejects.toThrow(); + it('ignores empty indices', async () => { + expect( + await alertService.updateAlertsStatus({ + ids: ['alert-id-1'], + status: CaseStatuses.closed, + indices: new Set(['']), + scopedClusterClient: esClient, + }) + ).toBeUndefined(); }); }); }); diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index 2776d6b40761e..320d32ac0d788 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -5,24 +5,26 @@ * 2.0. */ +import _ from 'lodash'; + import type { PublicMethodsOf } from '@kbn/utility-types'; -import { IClusterClient, KibanaRequest } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; export type AlertServiceContract = PublicMethodsOf; interface UpdateAlertsStatusArgs { - request: KibanaRequest; ids: string[]; status: CaseStatuses; - index: string; + indices: Set; + scopedClusterClient: ElasticsearchClient; } interface GetAlertsArgs { - request: KibanaRequest; ids: string[]; - index: string; + indices: Set; + scopedClusterClient: ElasticsearchClient; } interface Alert { @@ -37,29 +39,32 @@ interface AlertsResponse { }; } -export class AlertService { - private isInitialized = false; - private esClient?: IClusterClient; +/** + * remove empty strings from the indices, I'm not sure how likely this is but in the case that + * the document doesn't have _index set the security_solution code sets the value to an empty string + * instead + */ +function getValidIndices(indices: Set): string[] { + return [...indices].filter((index) => !_.isEmpty(index)); +} +export class AlertService { constructor() {} - public initialize(esClient: IClusterClient) { - if (this.isInitialized) { - throw new Error('AlertService already initialized'); + public async updateAlertsStatus({ + ids, + status, + indices, + scopedClusterClient, + }: UpdateAlertsStatusArgs) { + const sanitizedIndices = getValidIndices(indices); + if (sanitizedIndices.length <= 0) { + // log that we only had invalid indices + return; } - this.isInitialized = true; - this.esClient = esClient; - } - - public async updateAlertsStatus({ request, ids, status, index }: UpdateAlertsStatusArgs) { - if (!this.isInitialized) { - throw new Error('AlertService not initialized'); - } - - // The above check makes sure that esClient is defined. - const result = await this.esClient!.asScoped(request).asCurrentUser.updateByQuery({ - index, + const result = await scopedClusterClient.updateByQuery({ + index: sanitizedIndices, conflicts: 'abort', body: { script: { @@ -74,13 +79,17 @@ export class AlertService { return result; } - public async getAlerts({ request, ids, index }: GetAlertsArgs): Promise { - if (!this.isInitialized) { - throw new Error('AlertService not initialized'); + public async getAlerts({ + scopedClusterClient, + ids, + indices, + }: GetAlertsArgs): Promise { + const index = getValidIndices(indices); + if (index.length <= 0) { + return; } - // The above check makes sure that esClient is defined. - const result = await this.esClient!.asScoped(request).asCurrentUser.search({ + const result = await scopedClusterClient.search({ index, body: { query: { diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index a2ad98b603552..a9e5c26960830 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -7,7 +7,6 @@ import { KibanaRequest, - KibanaResponseFactory, Logger, SavedObject, SavedObjectsClientContract, @@ -16,6 +15,7 @@ import { SavedObjectReference, SavedObjectsBulkUpdateResponse, SavedObjectsBulkResponse, + SavedObjectsFindResult, } from 'kibana/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; @@ -25,8 +25,26 @@ import { SavedObjectFindOptions, User, CommentPatchAttributes, + SubCaseAttributes, + AssociationType, + SubCaseResponse, + CommentType, + CaseType, + CaseResponse, + caseTypeField, } from '../../common/api'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; +import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; +import { defaultPage, defaultPerPage } from '../routes/api'; +import { + flattenCaseSavedObject, + flattenSubCaseSavedObject, + transformNewSubCase, +} from '../routes/api/utils'; +import { + CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../saved_object_types'; import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; @@ -45,20 +63,50 @@ interface PushedArgs { } interface GetCaseArgs extends ClientArgs { - caseId: string; + id: string; } interface GetCasesArgs extends ClientArgs { caseIds: string[]; } -interface FindCommentsArgs extends GetCaseArgs { +interface GetSubCasesArgs extends ClientArgs { + ids: string[]; +} + +interface FindCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptions; +} + +interface FindCaseCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptions; + includeSubCaseComments?: boolean; +} + +interface FindSubCaseCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; options?: SavedObjectFindOptions; } interface FindCasesArgs extends ClientArgs { options?: SavedObjectFindOptions; } + +interface FindSubCasesByIDArgs extends FindCasesArgs { + ids: string[]; +} + +interface FindSubCasesStatusStats { + client: SavedObjectsClientContract; + options: SavedObjectFindOptions; + ids: string[]; +} + interface GetCommentArgs extends ClientArgs { commentId: string; } @@ -67,6 +115,12 @@ interface PostCaseArgs extends ClientArgs { attributes: ESCaseAttributes; } +interface CreateSubCaseArgs extends ClientArgs { + createdAt: string; + caseId: string; + createdBy: User; +} + interface PostCommentArgs extends ClientArgs { attributes: CommentAttributes; references: SavedObjectReference[]; @@ -95,20 +149,69 @@ interface PatchComments extends ClientArgs { comments: PatchComment[]; } +interface PatchSubCase { + client: SavedObjectsClientContract; + subCaseId: string; + updatedAttributes: Partial; + version?: string; +} + +interface PatchSubCases { + client: SavedObjectsClientContract; + subCases: Array>; +} + interface GetUserArgs { request: KibanaRequest; - response?: KibanaResponseFactory; } -interface CaseServiceDeps { - authentication: SecurityPluginSetup['authc'] | null; +interface SubCasesMapWithPageInfo { + subCasesMap: Map; + page: number; + perPage: number; +} + +interface CaseCommentStats { + commentTotals: Map; + alertTotals: Map; +} + +interface FindCommentsByAssociationArgs { + client: SavedObjectsClientContract; + id: string | string[]; + associationType: AssociationType; + options?: SavedObjectFindOptions; +} + +interface Collection { + case: SavedObjectsFindResult; + subCases?: SubCaseResponse[]; } + +interface CasesMapWithPageInfo { + casesMap: Map; + page: number; + perPage: number; +} + export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; + deleteSubCase(client: SavedObjectsClientContract, id: string): Promise<{}>; findCases(args: FindCasesArgs): Promise>; - getAllCaseComments(args: FindCommentsArgs): Promise>; + findSubCases(args: FindCasesArgs): Promise>; + findSubCasesByCaseId( + args: FindSubCasesByIDArgs + ): Promise>; + getAllCaseComments( + args: FindCaseCommentsArgs + ): Promise>; + getAllSubCaseComments( + args: FindSubCaseCommentsArgs + ): Promise>; getCase(args: GetCaseArgs): Promise>; + getSubCase(args: GetCaseArgs): Promise>; + getSubCases(args: GetSubCasesArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; getTags(args: ClientArgs): Promise; @@ -120,205 +223,902 @@ export interface CaseServiceSetup { patchCases(args: PatchCasesArgs): Promise>; patchComment(args: UpdateCommentArgs): Promise>; patchComments(args: PatchComments): Promise>; + getMostRecentSubCase( + client: SavedObjectsClientContract, + caseId: string + ): Promise | undefined>; + createSubCase(args: CreateSubCaseArgs): Promise>; + patchSubCase(args: PatchSubCase): Promise>; + patchSubCases(args: PatchSubCases): Promise>; + findSubCaseStatusStats(args: FindSubCasesStatusStats): Promise; + getCommentsByAssociation( + args: FindCommentsByAssociationArgs + ): Promise>; + getCaseCommentStats(args: { + client: SavedObjectsClientContract; + ids: string[]; + associationType: AssociationType; + }): Promise; + findSubCasesGroupByCase(args: { + client: SavedObjectsClientContract; + options?: SavedObjectFindOptions; + ids: string[]; + }): Promise; + findCaseStatusStats(args: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise; + findCasesGroupedByID(args: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise; } -export class CaseService { - constructor(private readonly log: Logger) {} - public setup = async ({ authentication }: CaseServiceDeps): Promise => ({ - deleteCase: async ({ client, caseId }: GetCaseArgs) => { - try { - this.log.debug(`Attempting to GET case ${caseId}`); - return await client.delete(CASE_SAVED_OBJECT, caseId); - } catch (error) { - this.log.debug(`Error on GET case ${caseId}: ${error}`); - throw error; - } - }, - deleteComment: async ({ client, commentId }: GetCommentArgs) => { - try { - this.log.debug(`Attempting to GET comment ${commentId}`); - return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); - } catch (error) { - this.log.debug(`Error on GET comment ${commentId}: ${error}`); - throw error; - } - }, - getCase: async ({ client, caseId }: GetCaseArgs) => { - try { - this.log.debug(`Attempting to GET case ${caseId}`); - return await client.get(CASE_SAVED_OBJECT, caseId); - } catch (error) { - this.log.debug(`Error on GET case ${caseId}: ${error}`); - throw error; +export class CaseService implements CaseServiceSetup { + constructor( + private readonly log: Logger, + private readonly authentication?: SecurityPluginSetup['authc'] + ) {} + + /** + * Returns a map of all cases combined with their sub cases if they are collections. + */ + public async findCasesGroupedByID({ + client, + caseOptions, + subCaseOptions, + }: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise { + const cases = await this.findCases({ + client, + options: caseOptions, + }); + + const subCasesResp = await this.findSubCasesGroupByCase({ + client, + options: subCaseOptions, + ids: cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) + .map((caseInfo) => caseInfo.id), + }); + + const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { + const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); + + /** + * This will include empty collections unless the query explicitly requested type === CaseType.individual, in which + * case we'd not have any collections anyway. + */ + accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); + return accMap; + }, new Map()); + + /** + * One potential optimization here is to get all comment stats for individual cases, parent cases, and sub cases + * in a single request. This can be done because comments that are for sub cases have a reference to both the sub case + * and the parent. The associationType field allows us to determine which type of case the comment is attached to. + * + * So we could use the ids for all the valid cases (individual cases and parents with sub cases) to grab everything. + * Once we have it we can build the maps. + * + * Currently we get all comment stats for all sub cases in one go and we get all comment stats for cases (individual and parent) + * in another request (the one below this comment). + */ + const totalCommentsForCases = await this.getCaseCommentStats({ + client, + ids: Array.from(casesMap.keys()), + associationType: AssociationType.case, + }); + + const casesWithComments = new Map(); + for (const [id, caseInfo] of casesMap.entries()) { + casesWithComments.set( + id, + flattenCaseSavedObject({ + savedObject: caseInfo.case, + totalComment: totalCommentsForCases.commentTotals.get(id) ?? 0, + totalAlerts: totalCommentsForCases.alertTotals.get(id) ?? 0, + subCases: caseInfo.subCases, + }) + ); + } + + return { + casesMap: casesWithComments, + page: cases.page, + perPage: cases.per_page, + }; + } + + /** + * Retrieves the number of cases that exist with a given status (open, closed, etc). + * This also counts sub cases. Parent cases are excluded from the statistics. + */ + public async findCaseStatusStats({ + client, + caseOptions, + subCaseOptions, + }: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise { + const casesStats = await this.findCases({ + client, + options: { + ...caseOptions, + fields: [], + page: 1, + perPage: 1, + }, + }); + + /** + * This could be made more performant. What we're doing here is retrieving all cases + * that match the API request's filters instead of just counts. This is because we need to grab + * the ids for the parent cases that match those filters. Then we use those IDS to count how many + * sub cases those parents have to calculate the total amount of cases that are open, closed, or in-progress. + * + * Another solution would be to store ALL filterable fields on both a case and sub case. That we could do a single + * query for each type to calculate the totals using the filters. This has drawbacks though: + * + * We'd have to sync up the parent case's editable attributes with the sub case any time they were change to avoid + * them getting out of sync and causing issues when we do these types of stats aggregations. This would result in a lot + * of update requests if the user is editing their case details often. Which could potentially cause conflict failures. + * + * Another option is to prevent the ability from update the parent case's details all together once it's created. A user + * could instead modify the sub case details directly. This could be weird though because individual sub cases for the same + * parent would have different titles, tags, etc. + * + * Another potential issue with this approach is when you push a case and all its sub case information. If the sub cases + * don't have the same title and tags, we'd need to account for that as well. + */ + const cases = await this.findCases({ + client, + options: { + ...caseOptions, + fields: [caseTypeField], + page: 1, + perPage: casesStats.total, + }, + }); + + const caseIds = cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) + .map((caseInfo) => caseInfo.id); + + let subCasesTotal = 0; + + if (subCaseOptions) { + subCasesTotal = await this.findSubCaseStatusStats({ + client, + options: subCaseOptions, + ids: caseIds, + }); + } + + const total = + cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.collection) + .length + subCasesTotal; + + return total; + } + + /** + * Retrieves the comments attached to a case or sub case. + */ + public async getCommentsByAssociation({ + client, + id, + associationType, + options, + }: FindCommentsByAssociationArgs): Promise> { + if (associationType === AssociationType.subCase) { + return this.getAllSubCaseComments({ + client, + id, + options, + }); + } else { + return this.getAllCaseComments({ + client, + id, + options, + }); + } + } + + /** + * Returns the number of total comments and alerts for a case (or sub case) + */ + public async getCaseCommentStats({ + client, + ids, + associationType, + }: { + client: SavedObjectsClientContract; + ids: string[]; + associationType: AssociationType; + }): Promise { + if (ids.length <= 0) { + return { + commentTotals: new Map(), + alertTotals: new Map(), + }; + } + + const refType = + associationType === AssociationType.case ? CASE_SAVED_OBJECT : SUB_CASE_SAVED_OBJECT; + + const allComments = await Promise.all( + ids.map((id) => + this.getCommentsByAssociation({ + client, + associationType, + id, + options: { page: 1, perPage: 1 }, + }) + ) + ); + + const alerts = await this.getCommentsByAssociation({ + client, + associationType, + id: ids, + options: { + filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert})`, + }, + }); + + const getID = (comments: SavedObjectsFindResponse) => { + return comments.saved_objects.length > 0 + ? comments.saved_objects[0].references.find((ref) => ref.type === refType)?.id + : undefined; + }; + + const groupedComments = allComments.reduce((acc, comments) => { + const id = getID(comments); + if (id) { + acc.set(id, comments.total); } - }, - getCases: async ({ client, caseIds }: GetCasesArgs) => { - try { - this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); - return await client.bulkGet( - caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) - ); - } catch (error) { - this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); - throw error; + return acc; + }, new Map()); + + const groupedAlerts = groupTotalAlertsByID({ comments: alerts }); + return { commentTotals: groupedComments, alertTotals: groupedAlerts }; + } + + /** + * Returns all the sub cases for a set of case IDs. Comment statistics are also returned. + */ + public async findSubCasesGroupByCase({ + client, + options, + ids, + }: { + client: SavedObjectsClientContract; + options?: SavedObjectFindOptions; + ids: string[]; + }): Promise { + const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { + return subCase.references.length > 0 ? subCase.references[0].id : undefined; + }; + + const emptyResponse = { + subCasesMap: new Map(), + page: 0, + perPage: 0, + }; + + if (!options) { + return emptyResponse; + } + + if (ids.length <= 0) { + return emptyResponse; + } + + const subCases = await this.findSubCases({ + client, + options: { + ...options, + hasReference: ids.map((id) => { + return { + id, + type: CASE_SAVED_OBJECT, + }; + }), + }, + }); + + const subCaseComments = await this.getCaseCommentStats({ + client, + ids: subCases.saved_objects.map((subCase) => subCase.id), + associationType: AssociationType.subCase, + }); + + const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { + const parentCaseID = getCaseID(subCase); + if (parentCaseID) { + const subCaseFromMap = accMap.get(parentCaseID); + + if (subCaseFromMap === undefined) { + const subCasesForID = [ + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, + }), + ]; + accMap.set(parentCaseID, subCasesForID); + } else { + subCaseFromMap.push( + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, + }) + ); + } } - }, - getComment: async ({ client, commentId }: GetCommentArgs) => { - try { - this.log.debug(`Attempting to GET comment ${commentId}`); - return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); - } catch (error) { - this.log.debug(`Error on GET comment ${commentId}: ${error}`); - throw error; + return accMap; + }, new Map()); + + return { subCasesMap, page: subCases.page, perPage: subCases.per_page }; + } + + /** + * Calculates the number of sub cases for a given set of options for a set of case IDs. + */ + public async findSubCaseStatusStats({ + client, + options, + ids, + }: FindSubCasesStatusStats): Promise { + if (ids.length <= 0) { + return 0; + } + + const subCases = await this.findSubCases({ + client, + options: { + ...options, + page: 1, + perPage: 1, + fields: [], + hasReference: ids.map((id) => { + return { + id, + type: CASE_SAVED_OBJECT, + }; + }), + }, + }); + + return subCases.total; + } + + public async createSubCase({ + client, + createdAt, + caseId, + createdBy, + }: CreateSubCaseArgs): Promise> { + try { + this.log.debug(`Attempting to POST a new sub case`); + return client.create(SUB_CASE_SAVED_OBJECT, transformNewSubCase({ createdAt, createdBy }), { + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + ], + }); + } catch (error) { + this.log.debug(`Error on POST a new sub case: ${error}`); + throw error; + } + } + + public async getMostRecentSubCase(client: SavedObjectsClientContract, caseId: string) { + try { + this.log.debug(`Attempting to find most recent sub case for caseID: ${caseId}`); + const subCases: SavedObjectsFindResponse = await client.find({ + perPage: 1, + sortField: 'created_at', + sortOrder: 'desc', + type: SUB_CASE_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + }); + if (subCases.saved_objects.length <= 0) { + return; } - }, - findCases: async ({ client, options }: FindCasesArgs) => { - try { - this.log.debug(`Attempting to GET all cases`); - return await client.find({ ...options, type: CASE_SAVED_OBJECT }); - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; + + return subCases.saved_objects[0]; + } catch (error) { + this.log.debug(`Error finding the most recent sub case for case: ${caseId}`); + throw error; + } + } + + public async deleteSubCase(client: SavedObjectsClientContract, id: string) { + try { + this.log.debug(`Attempting to DELETE sub case ${id}`); + return await client.delete(SUB_CASE_SAVED_OBJECT, id); + } catch (error) { + this.log.debug(`Error on DELETE sub case ${id}: ${error}`); + throw error; + } + } + + public async deleteCase({ client, id: caseId }: GetCaseArgs) { + try { + this.log.debug(`Attempting to DELETE case ${caseId}`); + return await client.delete(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on DELETE case ${caseId}: ${error}`); + throw error; + } + } + public async deleteComment({ client, commentId }: GetCommentArgs) { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + } + public async getCase({ + client, + id: caseId, + }: GetCaseArgs): Promise> { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.get(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + } + public async getSubCase({ client, id }: GetCaseArgs): Promise> { + try { + this.log.debug(`Attempting to GET sub case ${id}`); + return await client.get(SUB_CASE_SAVED_OBJECT, id); + } catch (error) { + this.log.debug(`Error on GET sub case ${id}: ${error}`); + throw error; + } + } + + public async getSubCases({ + client, + ids, + }: GetSubCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET sub cases ${ids.join(', ')}`); + return await client.bulkGet(ids.map((id) => ({ type: SUB_CASE_SAVED_OBJECT, id }))); + } catch (error) { + this.log.debug(`Error on GET cases ${ids.join(', ')}: ${error}`); + throw error; + } + } + + public async getCases({ + client, + caseIds, + }: GetCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); + return await client.bulkGet( + caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) + ); + } catch (error) { + this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); + throw error; + } + } + public async getComment({ + client, + commentId, + }: GetCommentArgs): Promise> { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + } + + public async findCases({ + client, + options, + }: FindCasesArgs): Promise> { + try { + this.log.debug(`Attempting to find cases`); + return await client.find({ + sortField: defaultSortField, + ...options, + type: CASE_SAVED_OBJECT, + }); + } catch (error) { + this.log.debug(`Error on find cases: ${error}`); + throw error; + } + } + + public async findSubCases({ + client, + options, + }: FindCasesArgs): Promise> { + try { + this.log.debug(`Attempting to find sub cases`); + // if the page or perPage options are set then respect those instead of trying to + // grab all sub cases + if (options?.page !== undefined || options?.perPage !== undefined) { + return client.find({ + sortField: defaultSortField, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); } - }, - getAllCaseComments: async ({ client, caseId, options }: FindCommentsArgs) => { - try { - this.log.debug(`Attempting to GET all comments for case ${caseId}`); - return await client.find({ + + const stats = await client.find({ + fields: [], + page: 1, + perPage: 1, + sortField: defaultSortField, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); + return client.find({ + page: 1, + perPage: stats.total, + sortField: defaultSortField, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); + } catch (error) { + this.log.debug(`Error on find sub cases: ${error}`); + throw error; + } + } + + /** + * Find sub cases using a collection's ID. This would try to retrieve the maximum amount of sub cases + * by default. + * + * @param id the saved object ID of the parent collection to find sub cases for. + */ + public async findSubCasesByCaseId({ + client, + ids, + options, + }: FindSubCasesByIDArgs): Promise> { + if (ids.length <= 0) { + return { + total: 0, + saved_objects: [], + page: options?.page ?? defaultPage, + per_page: options?.perPage ?? defaultPerPage, + }; + } + + try { + this.log.debug(`Attempting to GET sub cases for case collection id ${ids.join(', ')}`); + return this.findSubCases({ + client, + options: { ...options, + hasReference: ids.map((id) => ({ + type: CASE_SAVED_OBJECT, + id, + })), + }, + }); + } catch (error) { + this.log.debug( + `Error on GET all sub cases for case collection id ${ids.join(', ')}: ${error}` + ); + throw error; + } + } + + private asArray(id: string | string[] | undefined): string[] { + if (id === undefined) { + return []; + } else if (Array.isArray(id)) { + return id; + } else { + return [id]; + } + } + + private async getAllComments({ + client, + id, + options, + }: FindCommentsArgs): Promise> { + try { + this.log.debug(`Attempting to GET all comments for id ${id}`); + if (options?.page !== undefined || options?.perPage !== undefined) { + return client.find({ type: CASE_COMMENT_SAVED_OBJECT, - hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + sortField: defaultSortField, + ...options, }); - } catch (error) { - this.log.debug(`Error on GET all comments for case ${caseId}: ${error}`); - throw error; - } - }, - getReporters: async ({ client }: ClientArgs) => { - try { - this.log.debug(`Attempting to GET all reporters`); - return await readReporters({ client }); - } catch (error) { - this.log.debug(`Error on GET all reporters: ${error}`); - throw error; } - }, - getTags: async ({ client }: ClientArgs) => { - try { - this.log.debug(`Attempting to GET all cases`); - return await readTags({ client }); - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; - } - }, - getUser: async ({ request, response }: GetUserArgs) => { - try { - this.log.debug(`Attempting to authenticate a user`); - if (authentication != null) { - const user = authentication.getCurrentUser(request); - if (!user) { - return { - username: null, - full_name: null, - email: null, - }; - } - return user; - } + // get the total number of comments that are in ES then we'll grab them all in one go + const stats = await client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + fields: [], + page: 1, + perPage: 1, + sortField: defaultSortField, + // spread the options after so the caller can override the default behavior if they want + ...options, + }); + + return client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + page: 1, + perPage: stats.total, + sortField: defaultSortField, + ...options, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for ${id}: ${error}`); + throw error; + } + } + + /** + * Default behavior is to retrieve all comments that adhere to a given filter (if one is included). + * to override this pass in the either the page or perPage options. + * + * @param includeSubCaseComments is a flag to indicate that sub case comments should be included as well, by default + * sub case comments are excluded. If the `filter` field is included in the options, it will override this behavior + */ + public async getAllCaseComments({ + client, + id, + options, + includeSubCaseComments = false, + }: FindCaseCommentsArgs): Promise> { + try { + const refs = this.asArray(id).map((caseID) => ({ type: CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { return { - username: null, - full_name: null, - email: null, + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, }; - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; } - }, - postNewCase: async ({ client, attributes }: PostCaseArgs) => { - try { - this.log.debug(`Attempting to POST a new case`); - return await client.create(CASE_SAVED_OBJECT, { ...attributes }); - } catch (error) { - this.log.debug(`Error on POST a new case: ${error}`); - throw error; - } - }, - postNewComment: async ({ client, attributes, references }: PostCommentArgs) => { - try { - this.log.debug(`Attempting to POST a new comment`); - return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); - } catch (error) { - this.log.debug(`Error on POST a new comment: ${error}`); - throw error; - } - }, - patchCase: async ({ client, caseId, updatedAttributes, version }: PatchCaseArgs) => { - try { - this.log.debug(`Attempting to UPDATE case ${caseId}`); - return await client.update( - CASE_SAVED_OBJECT, - caseId, - { ...updatedAttributes }, - { version } - ); - } catch (error) { - this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); - throw error; - } - }, - patchCases: async ({ client, cases }: PatchCasesArgs) => { - try { - this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); - return await client.bulkUpdate( - cases.map((c) => ({ - type: CASE_SAVED_OBJECT, - id: c.caseId, - attributes: c.updatedAttributes, - version: c.version, - })) + + let filter: string | undefined; + if (!includeSubCaseComments) { + // if other filters were passed in then combine them to filter out sub case comments + filter = combineFilters( + [ + options?.filter ?? '', + `${CASE_COMMENT_SAVED_OBJECT}.attributes.associationType: ${AssociationType.case}`, + ], + 'AND' ); - } catch (error) { - this.log.debug(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); - throw error; } - }, - patchComment: async ({ client, commentId, updatedAttributes, version }: UpdateCommentArgs) => { - try { - this.log.debug(`Attempting to UPDATE comment ${commentId}`); - return await client.update( - CASE_COMMENT_SAVED_OBJECT, - commentId, - { - ...updatedAttributes, - }, - { version } - ); - } catch (error) { - this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); - throw error; + + this.log.debug(`Attempting to GET all comments for case caseID ${id}`); + return this.getAllComments({ + client, + id, + options: { + hasReferenceOperator: 'OR', + hasReference: refs, + filter, + ...options, + }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for case ${id}: ${error}`); + throw error; + } + } + + public async getAllSubCaseComments({ + client, + id, + options, + }: FindSubCaseCommentsArgs): Promise> { + try { + const refs = this.asArray(id).map((caseID) => ({ type: SUB_CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { + return { + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, + }; } - }, - patchComments: async ({ client, comments }: PatchComments) => { - try { - this.log.debug( - `Attempting to UPDATE comments ${comments.map((c) => c.commentId).join(', ')}` - ); - return await client.bulkUpdate( - comments.map((c) => ({ - type: CASE_COMMENT_SAVED_OBJECT, - id: c.commentId, - attributes: c.updatedAttributes, - version: c.version, - })) - ); - } catch (error) { - this.log.debug( - `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` - ); - throw error; + + this.log.debug(`Attempting to GET all comments for sub case caseID ${id}`); + return this.getAllComments({ + client, + id, + options: { + hasReferenceOperator: 'OR', + hasReference: refs, + ...options, + }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for sub case ${id}: ${error}`); + throw error; + } + } + + public async getReporters({ client }: ClientArgs) { + try { + this.log.debug(`Attempting to GET all reporters`); + return await readReporters({ client }); + } catch (error) { + this.log.debug(`Error on GET all reporters: ${error}`); + throw error; + } + } + public async getTags({ client }: ClientArgs) { + try { + this.log.debug(`Attempting to GET all cases`); + return await readTags({ client }); + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + } + + public async getUser({ request }: GetUserArgs) { + try { + this.log.debug(`Attempting to authenticate a user`); + if (this.authentication != null) { + const user = this.authentication.getCurrentUser(request); + if (!user) { + return { + username: null, + full_name: null, + email: null, + }; + } + return user; } - }, - }); + return { + username: null, + full_name: null, + email: null, + }; + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + } + public async postNewCase({ client, attributes }: PostCaseArgs) { + try { + this.log.debug(`Attempting to POST a new case`); + return await client.create(CASE_SAVED_OBJECT, { ...attributes }); + } catch (error) { + this.log.debug(`Error on POST a new case: ${error}`); + throw error; + } + } + public async postNewComment({ client, attributes, references }: PostCommentArgs) { + try { + this.log.debug(`Attempting to POST a new comment`); + return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); + } catch (error) { + this.log.debug(`Error on POST a new comment: ${error}`); + throw error; + } + } + public async patchCase({ client, caseId, updatedAttributes, version }: PatchCaseArgs) { + try { + this.log.debug(`Attempting to UPDATE case ${caseId}`); + return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }, { version }); + } catch (error) { + this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); + throw error; + } + } + public async patchCases({ client, cases }: PatchCasesArgs) { + try { + this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); + return await client.bulkUpdate( + cases.map((c) => ({ + type: CASE_SAVED_OBJECT, + id: c.caseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); + throw error; + } + } + public async patchComment({ client, commentId, updatedAttributes, version }: UpdateCommentArgs) { + try { + this.log.debug(`Attempting to UPDATE comment ${commentId}`); + return await client.update( + CASE_COMMENT_SAVED_OBJECT, + commentId, + { + ...updatedAttributes, + }, + { version } + ); + } catch (error) { + this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); + throw error; + } + } + public async patchComments({ client, comments }: PatchComments) { + try { + this.log.debug( + `Attempting to UPDATE comments ${comments.map((c) => c.commentId).join(', ')}` + ); + return await client.bulkUpdate( + comments.map((c) => ({ + type: CASE_COMMENT_SAVED_OBJECT, + id: c.commentId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` + ); + throw error; + } + } + public async patchSubCase({ client, subCaseId, updatedAttributes, version }: PatchSubCase) { + try { + this.log.debug(`Attempting to UPDATE sub case ${subCaseId}`); + return await client.update( + SUB_CASE_SAVED_OBJECT, + subCaseId, + { ...updatedAttributes }, + { version } + ); + } catch (error) { + this.log.debug(`Error on UPDATE sub case ${subCaseId}: ${error}`); + throw error; + } + } + + public async patchSubCases({ client, subCases }: PatchSubCases) { + try { + this.log.debug( + `Attempting to UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}` + ); + return await client.bulkUpdate( + subCases.map((c) => ({ + type: SUB_CASE_SAVED_OBJECT, + id: c.subCaseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}: ${error}` + ); + throw error; + } + } } diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 0b3615793ef85..51eb0bbb1a7e4 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -20,13 +20,21 @@ export type CaseUserActionServiceMock = jest.Mocked; export type AlertServiceMock = jest.Mocked; export const createCaseServiceMock = (): CaseServiceMock => ({ + createSubCase: jest.fn(), deleteCase: jest.fn(), deleteComment: jest.fn(), + deleteSubCase: jest.fn(), findCases: jest.fn(), + findSubCases: jest.fn(), + findSubCasesByCaseId: jest.fn(), getAllCaseComments: jest.fn(), + getAllSubCaseComments: jest.fn(), getCase: jest.fn(), getCases: jest.fn(), getComment: jest.fn(), + getMostRecentSubCase: jest.fn(), + getSubCase: jest.fn(), + getSubCases: jest.fn(), getTags: jest.fn(), getReporters: jest.fn(), getUser: jest.fn(), @@ -36,6 +44,14 @@ export const createCaseServiceMock = (): CaseServiceMock => ({ patchCases: jest.fn(), patchComment: jest.fn(), patchComments: jest.fn(), + patchSubCase: jest.fn(), + patchSubCases: jest.fn(), + findSubCaseStatusStats: jest.fn(), + getCommentsByAssociation: jest.fn(), + getCaseCommentStats: jest.fn(), + findSubCasesGroupByCase: jest.fn(), + findCaseStatusStats: jest.fn(), + findCasesGroupedByID: jest.fn(), }); export const createConfigureServiceMock = (): CaseConfigureServiceMock => ({ @@ -57,7 +73,6 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ }); export const createAlertServiceMock = (): AlertServiceMock => ({ - initialize: jest.fn(), updateAlertsStatus: jest.fn(), getAlerts: jest.fn(), }); diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index 9a32a04d62300..c600a96234b3d 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -15,13 +15,19 @@ import { UserActionField, ESCaseAttributes, User, + UserActionFieldType, + SubCaseAttributes, } from '../../../common/api'; import { isTwoArraysDifference, transformESConnectorToCaseConnector, } from '../../routes/api/cases/helpers'; import { UserActionItem } from '.'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; +import { + CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; export const transformNewUserAction = ({ actionField, @@ -59,6 +65,7 @@ interface BuildCaseUserAction { fields: UserActionField | unknown[]; newValue?: string | unknown; oldValue?: string | unknown; + subCaseId?: string; } interface BuildCommentUserActionItem extends BuildCaseUserAction { @@ -74,6 +81,7 @@ export const buildCommentUserActionItem = ({ fields, newValue, oldValue, + subCaseId, }: BuildCommentUserActionItem): UserActionItem => ({ attributes: transformNewUserAction({ actionField: fields as UserActionField, @@ -94,6 +102,15 @@ export const buildCommentUserActionItem = ({ name: `associated-${CASE_COMMENT_SAVED_OBJECT}`, id: commentId, }, + ...(subCaseId + ? [ + { + type: SUB_CASE_SAVED_OBJECT, + id: subCaseId, + name: `associated-${SUB_CASE_SAVED_OBJECT}`, + }, + ] + : []), ], }); @@ -105,6 +122,7 @@ export const buildCaseUserActionItem = ({ fields, newValue, oldValue, + subCaseId, }: BuildCaseUserAction): UserActionItem => ({ attributes: transformNewUserAction({ actionField: fields as UserActionField, @@ -120,6 +138,15 @@ export const buildCaseUserActionItem = ({ name: `associated-${CASE_SAVED_OBJECT}`, id: caseId, }, + ...(subCaseId + ? [ + { + type: SUB_CASE_SAVED_OBJECT, + name: `associated-${SUB_CASE_SAVED_OBJECT}`, + id: subCaseId, + }, + ] + : []), ], }); @@ -131,35 +158,57 @@ const userActionFieldsAllowed: UserActionField = [ 'title', 'status', 'settings', + 'sub_case', ]; -export const buildCaseUserActions = ({ +interface CaseSubIDs { + caseId: string; + subCaseId?: string; +} + +type GetCaseAndSubID = (so: SavedObjectsUpdateResponse) => CaseSubIDs; +type GetField = ( + attributes: Pick, 'attributes'>, + field: UserActionFieldType +) => unknown; + +/** + * Abstraction functions to retrieve a given field and the caseId and subCaseId depending on + * whether we're interacting with a case or a sub case. + */ +interface Getters { + getField: GetField; + getCaseAndSubID: GetCaseAndSubID; +} + +const buildGenericCaseUserActions = ({ actionDate, actionBy, originalCases, updatedCases, + allowedFields, + getters, }: { actionDate: string; actionBy: User; - originalCases: Array>; - updatedCases: Array>; -}): UserActionItem[] => - updatedCases.reduce((acc, updatedItem) => { + originalCases: Array>; + updatedCases: Array>; + allowedFields: UserActionField; + getters: Getters; +}): UserActionItem[] => { + const { getCaseAndSubID, getField } = getters; + return updatedCases.reduce((acc, updatedItem) => { + const { caseId, subCaseId } = getCaseAndSubID(updatedItem); + // regardless of whether we're looking at a sub case or case, the id field will always be used to match between + // the original and the updated saved object const originalItem = originalCases.find((oItem) => oItem.id === updatedItem.id); if (originalItem != null) { let userActions: UserActionItem[] = []; const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; updatedFields.forEach((field) => { - if (userActionFieldsAllowed.includes(field)) { - const origValue = - field === 'connector' && originalItem.attributes.connector - ? transformESConnectorToCaseConnector(originalItem.attributes.connector) - : get(originalItem, ['attributes', field]); - - const updatedValue = - field === 'connector' && updatedItem.attributes.connector - ? transformESConnectorToCaseConnector(updatedItem.attributes.connector) - : get(updatedItem, ['attributes', field]); + if (allowedFields.includes(field)) { + const origValue = getField(originalItem, field); + const updatedValue = getField(updatedItem, field); if (isString(origValue) && isString(updatedValue) && origValue !== updatedValue) { userActions = [ @@ -168,7 +217,8 @@ export const buildCaseUserActions = ({ action: 'update', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: updatedValue, oldValue: origValue, @@ -183,7 +233,8 @@ export const buildCaseUserActions = ({ action: 'add', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: compareValues.addedItems.join(', '), }), @@ -197,7 +248,8 @@ export const buildCaseUserActions = ({ action: 'delete', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: compareValues.deletedItems.join(', '), }), @@ -214,7 +266,8 @@ export const buildCaseUserActions = ({ action: 'update', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: JSON.stringify(updatedValue), oldValue: JSON.stringify(origValue), @@ -227,3 +280,68 @@ export const buildCaseUserActions = ({ } return acc; }, []); +}; + +/** + * Create a user action for an updated sub case. + */ +export const buildSubCaseUserActions = (args: { + actionDate: string; + actionBy: User; + originalSubCases: Array>; + updatedSubCases: Array>; +}): UserActionItem[] => { + const getField = ( + so: Pick, 'attributes'>, + field: UserActionFieldType + ) => get(so, ['attributes', field]); + + const getCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { + const caseId = so.references?.find((ref) => ref.type === CASE_SAVED_OBJECT)?.id ?? ''; + return { caseId, subCaseId: so.id }; + }; + + const getters: Getters = { + getField, + getCaseAndSubID, + }; + + return buildGenericCaseUserActions({ + actionDate: args.actionDate, + actionBy: args.actionBy, + originalCases: args.originalSubCases, + updatedCases: args.updatedSubCases, + allowedFields: ['status'], + getters, + }); +}; + +/** + * Create a user action for an updated case. + */ +export const buildCaseUserActions = (args: { + actionDate: string; + actionBy: User; + originalCases: Array>; + updatedCases: Array>; +}): UserActionItem[] => { + const getField = ( + so: Pick, 'attributes'>, + field: UserActionFieldType + ) => { + return field === 'connector' && so.attributes.connector + ? transformESConnectorToCaseConnector(so.attributes.connector) + : get(so, ['attributes', field]); + }; + + const caseGetIds: GetCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { + return { caseId: so.id }; + }; + + const getters: Getters = { + getField, + getCaseAndSubID: caseGetIds, + }; + + return buildGenericCaseUserActions({ ...args, allowedFields: userActionFieldsAllowed, getters }); +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index e1e9ac77a547a..318143426af58 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -14,7 +14,7 @@ import { TestProviders } from '../../../common/mock'; import { casesStatus, useGetCasesMockState } from '../../containers/mock'; import * as i18n from './translations'; -import { CaseStatuses } from '../../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../../case/common/api'; import { useKibana } from '../../../common/lib/kibana'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; @@ -544,7 +544,9 @@ describe('AllCases', () => { status: 'open', tags: ['coke', 'pepsi'], title: 'Another horrible breach!!', + totalAlerts: 0, totalComment: 0, + type: CaseType.individual, updatedAt: '2020-02-20T15:02:57.995Z', updatedBy: { email: 'leslie.knope@elastic.co', diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx index be43704fcbba1..a1ee825aa5337 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx @@ -5,13 +5,14 @@ * 2.0. */ -import { CommentType } from '../../../../../case/common/api'; +import { AssociationType, CommentType } from '../../../../../case/common/api'; import { Comment } from '../../containers/types'; import { getRuleIdsFromComments, buildAlertsQuery } from './helpers'; const comments: Comment[] = [ { + associationType: AssociationType.case, type: CommentType.alert, alertId: 'alert-id-1', index: 'alert-index-1', @@ -25,6 +26,7 @@ const comments: Comment[] = [ version: 'WzQ3LDFc', }, { + associationType: AssociationType.case, type: CommentType.alert, alertId: 'alert-id-2', index: 'alert-index-2', diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts index ac0dc96eda526..6b92e414675e2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts @@ -11,7 +11,8 @@ import { Comment } from '../../containers/types'; export const getRuleIdsFromComments = (comments: Comment[]) => comments.reduce((ruleIds, comment: Comment) => { if (comment.type === CommentType.alert) { - return [...ruleIds, comment.alertId]; + const ids = Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + return [...ruleIds, ...ids]; } return ruleIds; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx index 1e2b34ddf38ea..656257f2b36c4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx @@ -26,9 +26,10 @@ const Container = styled.div` `; const defaultAlertComment = { - type: CommentType.alert, - alertId: '{{context.rule.id}}', + type: CommentType.generatedAlert, + alerts: '{{context.alerts}}', index: '{{context.rule.output_index}}', + ruleId: '{{context.rule.id}}', }; const CaseParamsFields: React.FunctionComponent> = ({ diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index 4a567a38dc9f2..3b81fc0afccf3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -335,8 +335,15 @@ export const UserActionTree = React.memo( ), }, ]; + // TODO: need to handle CommentType.generatedAlert here to } else if (comment != null && comment.type === CommentType.alert) { - const alert = alerts[comment.alertId]; + // TODO: clean this up + const alertId = Array.isArray(comment.alertId) + ? comment.alertId.length > 0 + ? comment.alertId[0] + : '' + : comment.alertId; + const alert = alerts[alertId]; return [...comments, getAlertComment({ action, alert, onShowAlertDetails })]; } } diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 444a87a57d251..80d4816bedd53 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -18,6 +18,8 @@ import { CasesResponse, CasesFindResponse, CommentType, + AssociationType, + CaseType, } from '../../../../case/common/api'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import { ConnectorTypes } from '../../../../case/common/api/connectors'; @@ -38,6 +40,7 @@ export const elasticUser = { export const tags: string[] = ['coke', 'pepsi']; export const basicComment: Comment = { + associationType: AssociationType.case, comment: 'Solve this fast!', type: CommentType.user, id: basicCommentId, @@ -52,6 +55,7 @@ export const basicComment: Comment = { export const alertComment: Comment = { alertId: 'alert-id-1', + associationType: AssociationType.case, index: 'alert-index-1', type: CommentType.alert, id: 'alert-comment-id', @@ -65,6 +69,7 @@ export const alertComment: Comment = { }; export const basicCase: Case = { + type: CaseType.individual, closedAt: null, closedBy: null, id: basicCaseId, @@ -83,6 +88,7 @@ export const basicCase: Case = { tags, title: 'Another horrible breach!!', totalComment: 1, + totalAlerts: 0, updatedAt: basicUpdatedAt, updatedBy: elasticUser, version: 'WzQ3LDFd', @@ -181,6 +187,7 @@ export const elasticUserSnake = { }; export const basicCommentSnake: CommentResponse = { + associationType: AssociationType.case, comment: 'Solve this fast!', type: CommentType.user, id: basicCommentId, diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index e5477cbd951ae..30ea834443468 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -14,11 +14,14 @@ import { CaseStatuses, CaseAttributes, CasePatchRequest, + CaseType, + AssociationType, } from '../../../../case/common/api'; export { CaseConnector, ActionConnector } from '../../../../case/common/api'; export type Comment = CommentRequest & { + associationType: AssociationType; id: string; createdAt: string; createdBy: ElasticUser; @@ -62,7 +65,9 @@ export interface Case { status: CaseStatuses; tags: string[]; title: string; + totalAlerts: number; totalComment: number; + type: CaseType; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 8f2e9a4f1d7cd..45827a4bebff8 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -6,7 +6,7 @@ */ import { useEffect, useReducer, useCallback } from 'react'; -import { CaseStatuses } from '../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../case/common/api'; import { Case } from './types'; import * as i18n from './translations'; @@ -71,7 +71,9 @@ export const initialData: Case = { status: CaseStatuses.open, tags: [], title: '', + totalAlerts: 0, totalComment: 0, + type: CaseType.individual, updatedAt: null, updatedBy: null, version: '', diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 164ccfd738919..5003d49136b7c 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -116,6 +116,14 @@ const securitySubPlugins = [ `${APP_ID}:${SecurityPageName.administration}`, ]; +const caseSavedObjects = [ + 'cases', + 'cases-comments', + 'cases-sub-case', + 'cases-configure', + 'cases-user-actions', +]; + export class Plugin implements IPlugin { private readonly logger: Logger; private readonly config: ConfigType; @@ -215,10 +223,7 @@ export class Plugin implements IPlugin { @@ -42,7 +50,6 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .expect(204) .send(); - expect(comment).to.eql({}); }); @@ -77,5 +84,67 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(404); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('deletes a comment from a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .delete( + `${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}?subCaseID=${ + caseInfo.subCase!.id + }` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + const { body } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + ); + expect(body.length).to.eql(0); + }); + + it('deletes all comments from a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + let { body: allComments } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + ); + expect(allComments.length).to.eql(2); + + await supertest + .delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + ({ body: allComments } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}` + )); + + // no comments for the sub case + expect(allComments.length).to.eql(0); + + ({ body: allComments } = await supertest.get(`${CASES_URL}/${caseInfo.id}/comments`)); + + // no comments for the collection + expect(allComments.length).to.eql(0); + }); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts index 824ea40d38ace..585333291111e 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts @@ -9,9 +9,17 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { CommentsResponse, CommentType } from '../../../../../../plugins/case/common/api'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -102,5 +110,35 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(400); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('finds comments for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: subCaseComments }: { body: CommentsResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`) + .send() + .expect(200); + expect(subCaseComments.total).to.be(2); + expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); + expect(subCaseComments.comments[1].type).to.be(CommentType.user); + }); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts new file mode 100644 index 0000000000000..1af16f9e54563 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts @@ -0,0 +1,109 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { CommentType } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_all_comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should get multiple comments for a single case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(comments.length).to.eql(2); + }); + + it('should get comments from a case and its sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments?includeSubCaseComments=true`) + .expect(200); + + expect(comments.length).to.eql(2); + expect(comments[0].type).to.eql(CommentType.generatedAlert); + expect(comments[1].type).to.eql(CommentType.user); + }); + + it('should get comments from a sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.subCase!.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .expect(200); + + expect(comments.length).to.eql(2); + expect(comments[0].type).to.eql(CommentType.generatedAlert); + expect(comments[1].type).to.eql(CommentType.user); + }); + + it('should not find any comments for an invalid case id', async () => { + const { body } = await supertest + .get(`${CASES_URL}/fake-id/comments`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(0); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts index 89efc927de5e3..389ec3f088f95 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts @@ -10,7 +10,13 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { CommentResponse, CommentType } from '../../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -18,10 +24,15 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('get_comment', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should get a comment', async () => { @@ -45,6 +56,15 @@ export default ({ getService }: FtrProviderContext): void => { expect(comment).to.eql(patchedCase.comments[0]); }); + + it('should get a sub case comment', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const { body: comment }: { body: CommentResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}`) + .expect(200); + expect(comment.type).to.be(CommentType.generatedAlert); + }); + it('unhappy path - 404s when comment is not there', async () => { await supertest .get(`${CASES_URL}/fake-id/comments/fake-comment`) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts index 73cce973eef94..2250b481c3729 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts @@ -10,14 +10,25 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { + CollectionWithSubCaseResponse, + CommentType, +} from '../../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCommentUserReq, postCommentAlertReq, } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -31,6 +42,79 @@ export default ({ getService }: FtrProviderContext): void => { await deleteCasesUserActions(es); }); + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('patches a comment for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const { + body: patchedSubCase, + }: { body: CollectionWithSubCaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + const { body: patchedSubCaseUpdatedComment } = await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedSubCase.subCase!.comments![1].id, + version: patchedSubCase.subCase!.comments![1].version, + comment: newComment, + type: CommentType.user, + }) + .expect(200); + + expect(patchedSubCaseUpdatedComment.subCase.comments.length).to.be(2); + expect(patchedSubCaseUpdatedComment.subCase.comments[0].type).to.be( + CommentType.generatedAlert + ); + expect(patchedSubCaseUpdatedComment.subCase.comments[1].type).to.be(CommentType.user); + expect(patchedSubCaseUpdatedComment.subCase.comments[1].comment).to.be(newComment); + }); + + it('fails to update the generated alert comment type', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send({ + id: caseInfo.subCase!.comments![0].id, + version: caseInfo.subCase!.comments![0].version, + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }) + .expect(400); + }); + + it('fails to update the generated alert comment by using another generated alert comment', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send({ + id: caseInfo.subCase!.comments![0].id, + version: caseInfo.subCase!.comments![0].version, + type: CommentType.generatedAlert, + alerts: [{ _id: 'id1' }], + index: 'test-index', + }) + .expect(400); + }); + }); + it('should patch a comment', async () => { const { body: postedCase } = await supertest .post(CASES_URL) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts index 087eb79dde7d2..1ce011985d9e6 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts @@ -11,14 +11,24 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/case/common/constants'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../../plugins/security_solution/common/constants'; -import { CommentType } from '../../../../../../plugins/case/common/api'; +import { CommentsResponse, CommentType } from '../../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCommentUserReq, postCommentAlertReq, + postCollectionReq, + postCommentGenAlertReq, } from '../../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCases, + deleteCasesUserActions, + deleteComments, +} from '../../../../common/lib/utils'; import { createSignalsIndex, deleteSignalsIndex, @@ -209,6 +219,34 @@ export default ({ getService }: FtrProviderContext): void => { .expect(400); }); + it('400s when adding an alert to a collection case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(400); + }); + + it('400s when adding a generated alert to an individual case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentGenAlertReq) + .expect(400); + }); + describe('alerts', () => { beforeEach(async () => { await esArchiver.load('auditbeat/hosts'); @@ -321,5 +359,37 @@ export default ({ getService }: FtrProviderContext): void => { expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); }); }); + + describe('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('posts a new comment for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // create another sub case just to make sure we get the right comments + await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: subCaseComments }: { body: CommentsResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`) + .send() + .expect(200); + expect(subCaseComments.total).to.be(2); + expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); + expect(subCaseComments.comments[1].type).to.be(CommentType.user); + }); + }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts index 41a9c822efda7..a2bc0acbcf17c 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts @@ -8,9 +8,17 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; -import { CASES_URL } from '../../../../../plugins/case/common/constants'; +import { CASES_URL, SUB_CASES_PATCH_DEL_URL } from '../../../../../plugins/case/common/constants'; import { postCaseReq, postCommentUserReq, findCasesResp } from '../../../common/lib/mock'; -import { deleteCases, deleteComments, deleteCasesUserActions } from '../../../common/lib/utils'; +import { + deleteAllCaseItems, + createSubCase, + setStatus, + CreateSubCaseResp, + createCaseAction, + deleteCaseAction, +} from '../../../common/lib/utils'; +import { CasesFindResponse, CaseStatuses, CaseType } from '../../../../../plugins/case/common/api'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -18,9 +26,7 @@ export default ({ getService }: FtrProviderContext): void => { const es = getService('es'); describe('find_cases', () => { afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should return empty response', async () => { @@ -242,6 +248,130 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.count_in_progress_cases).to.eql(1); }); + describe('stats with sub cases', () => { + let collection: CreateSubCaseResp; + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + beforeEach(async () => { + collection = await createSubCase({ supertest, actionID }); + + const [, , { body: toCloseCase }] = await Promise.all([ + setStatus({ + supertest, + cases: [ + { + id: collection.newSubCaseInfo.subCase!.id, + version: collection.newSubCaseInfo.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }), + supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), + supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), + ]); + + await setStatus({ + supertest, + cases: [ + { + id: toCloseCase.id, + version: toCloseCase.version, + status: CaseStatuses.closed, + }, + ], + type: 'case', + }); + }); + it('correctly counts stats without using a filter', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .expect(200); + + expect(body.total).to.eql(3); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(1); + }); + + it('correctly counts stats with a filter for open cases', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&status=open`) + .expect(200); + + expect(body.total).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(1); + }); + + it('correctly counts stats with a filter for individual cases', async () => { + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.individual}`) + .expect(200); + + expect(body.total).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats with a filter for collection cases with multiple sub cases', async () => { + // this will force the first sub case attached to the collection to be closed + // so we'll have one closed sub case and one open sub case + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}`) + .expect(200); + + expect(body.total).to.eql(1); + expect(body.cases[0].subCases?.length).to.eql(2); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats with a filter for collection and open cases with multiple sub cases', async () => { + // this will force the first sub case attached to the collection to be closed + // so we'll have one closed sub case and one open sub case + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); + const { body }: { body: CasesFindResponse } = await supertest + .get( + `${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}&status=${CaseStatuses.open}` + ) + .expect(200); + + expect(body.total).to.eql(1); + expect(body.cases[0].subCases?.length).to.eql(1); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats including a collection without sub cases', async () => { + // delete the sub case on the collection so that it doesn't have any sub cases + await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCase!.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + const { body }: { body: CasesFindResponse } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .expect(200); + + expect(body.total).to.eql(3); + expect(body.count_closed_cases).to.eql(1); + expect(body.count_open_cases).to.eql(1); + expect(body.count_in_progress_cases).to.eql(0); + }); + }); + it('unhappy path - 400s when bad query supplied', async () => { await supertest .get(`${CASES_URL}/_find?perPage=true`) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts index 1b51ec9ba1171..dcc49152e4db8 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts @@ -10,14 +10,17 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../plugins/case/common/constants'; import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../plugins/security_solution/common/constants'; -import { CommentType } from '../../../../../plugins/case/common/api'; +import { CaseType, CommentType } from '../../../../../plugins/case/common/api'; import { defaultUser, postCaseReq, postCaseResp, + postCollectionReq, + postCommentAlertReq, + postCommentUserReq, removeServerGeneratedPropertiesFromCase, } from '../../../common/lib/mock'; -import { deleteCases, deleteCasesUserActions } from '../../../common/lib/utils'; +import { deleteAllCaseItems } from '../../../common/lib/utils'; import { createSignalsIndex, deleteSignalsIndex, @@ -38,8 +41,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('patch_cases', () => { afterEach(async () => { - await deleteCases(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should patch a case', async () => { @@ -127,6 +129,106 @@ export default ({ getService }: FtrProviderContext): void => { .expect(404); }); + it('should 400 and not allow converting a collection back to an individual case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + type: CaseType.individual, + }, + ], + }) + .expect(400); + }); + + it('should allow converting an individual case to a collection when it does not have alerts', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: patchedCase.id, + version: patchedCase.version, + type: CaseType.collection, + }, + ], + }) + .expect(200); + }); + + it('should 400 when attempting to update an individual case to a collection when it has alerts attached to it', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: patchedCase.id, + version: patchedCase.version, + type: CaseType.collection, + }, + ], + }) + .expect(400); + }); + + it("should 400 when attempting to update a collection case's status", async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(400); + }); + it('unhappy path - 406s when excess data sent', async () => { const { body: postedCase } = await supertest .post(CASES_URL) diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts new file mode 100644 index 0000000000000..537afbe825068 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts @@ -0,0 +1,102 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + CASES_URL, + SUB_CASES_PATCH_DEL_URL, +} from '../../../../../../plugins/case/common/constants'; +import { postCommentUserReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { CollectionWithSubCaseResponse } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('delete_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should delete a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + expect(caseInfo.subCase?.id).to.not.eql(undefined); + + const { body: subCase } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .send() + .expect(200); + + expect(subCase.id).to.not.eql(undefined); + + const { body } = await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${subCase.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + expect(body).to.eql({}); + await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .send() + .expect(404); + }); + + it(`should delete a sub case's comments when that case gets deleted`, async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + expect(caseInfo.subCase?.id).to.not.eql(undefined); + + // there should be two comments on the sub case now + const { + body: patchedCaseWithSubCase, + }: { body: CollectionWithSubCaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments`) + .set('kbn-xsrf', 'true') + .query({ subCaseID: caseInfo.subCase!.id }) + .send(postCommentUserReq) + .expect(200); + + const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${ + patchedCaseWithSubCase.subCase!.comments![1].id + }`; + // make sure we can get the second comment + await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200); + + await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${patchedCaseWithSubCase.subCase!.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(404); + }); + + it('unhappy path - 404s when sub case id is invalid', async () => { + await supertest + .delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["fake-id"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts new file mode 100644 index 0000000000000..3463b37250980 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts @@ -0,0 +1,254 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { findSubCasesResp, postCollectionReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + setStatus, +} from '../../../../common/lib/utils'; +import { getSubCasesUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { + CaseResponse, + CaseStatuses, + SubCasesFindResponse, +} from '../../../../../../plugins/case/common/api'; +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('find_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should not find any sub cases when none exist', async () => { + const { body: caseResp }: { body: CaseResponse } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCollectionReq) + .expect(200); + + const { body: findSubCases } = await supertest + .get(`${getSubCasesUrl(caseResp.id)}/_find`) + .expect(200); + + expect(findSubCases).to.eql({ + page: 1, + per_page: 20, + total: 0, + subCases: [], + count_open_cases: 0, + count_closed_cases: 0, + count_in_progress_cases: 0, + }); + }); + + it('should return a sub cases with comment stats', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find`) + .expect(200); + + expect(body).to.eql({ + ...findSubCasesResp, + total: 1, + // find should not return the comments themselves only the stats + subCases: [{ ...caseInfo.subCase!, comments: [], totalComment: 1, totalAlerts: 2 }], + count_open_cases: 1, + }); + }); + + it('should return multiple sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const subCase2Resp = await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find`) + .expect(200); + + expect(body).to.eql({ + ...findSubCasesResp, + total: 2, + // find should not return the comments themselves only the stats + subCases: [ + { + // there should only be 1 closed sub case + ...subCase2Resp.modifiedSubCases![0], + comments: [], + totalComment: 1, + totalAlerts: 2, + status: CaseStatuses.closed, + }, + { + ...subCase2Resp.newSubCaseInfo.subCase, + comments: [], + totalComment: 1, + totalAlerts: 2, + }, + ], + count_open_cases: 1, + count_closed_cases: 1, + }); + }); + + it('should only return open when filtering for open', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses.open}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses.open); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should only return closed when filtering for closed', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ supertest, caseID: caseInfo.id, actionID }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses.closed}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should only return in progress when filtering for in progress', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + const { newSubCaseInfo: secondSub } = await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + await setStatus({ + supertest, + cases: [ + { + id: secondSub.subCase!.id, + version: secondSub.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?status=${CaseStatuses['in-progress']}`) + .expect(200); + + expect(body.total).to.be(1); + expect(body.subCases[0].status).to.be(CaseStatuses['in-progress']); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(0); + expect(body.count_in_progress_cases).to.be(1); + }); + + it('should sort on createdAt field in descending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=createdAt&sortOrder=desc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.open); + expect(body.subCases[1].status).to.be(CaseStatuses.closed); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should sort on createdAt field in ascending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=createdAt&sortOrder=asc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.subCases[1].status).to.be(CaseStatuses.open); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(1); + expect(body.count_in_progress_cases).to.be(0); + }); + + it('should sort on updatedAt field in ascending order', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // this will result in one closed case and one open + const { newSubCaseInfo: secondSub } = await createSubCase({ + supertest, + caseID: caseInfo.id, + actionID, + }); + + await setStatus({ + supertest, + cases: [ + { + id: secondSub.subCase!.id, + version: secondSub.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + + const { body }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(caseInfo.id)}/_find?sortField=updatedAt&sortOrder=asc`) + .expect(200); + + expect(body.total).to.be(2); + expect(body.subCases[0].status).to.be(CaseStatuses.closed); + expect(body.subCases[1].status).to.be(CaseStatuses['in-progress']); + expect(body.count_closed_cases).to.be(1); + expect(body.count_open_cases).to.be(0); + expect(body.count_in_progress_cases).to.be(1); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts new file mode 100644 index 0000000000000..cd5a1ed85742f --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts @@ -0,0 +1,115 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + commentsResp, + postCommentAlertReq, + removeServerGeneratedPropertiesFromComments, + removeServerGeneratedPropertiesFromSubCase, + subCaseResp, +} from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + defaultCreateSubComment, + deleteAllCaseItems, + deleteCaseAction, +} from '../../../../common/lib/utils'; +import { + getCaseCommentsUrl, + getSubCaseDetailsUrl, +} from '../../../../../../plugins/case/common/api/helpers'; +import { + AssociationType, + CollectionWithSubCaseResponse, + SubCaseResponse, +} from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_sub_case', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return a case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + const { body }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( + commentsResp({ + comments: [{ comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }], + associationType: AssociationType.subCase, + }) + ); + + expect(removeServerGeneratedPropertiesFromSubCase(body)).to.eql( + subCaseResp({ id: body.id, totalComment: 1, totalAlerts: 2 }) + ); + }); + + it('should return the correct number of alerts with multiple types of alerts', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + const { body: singleAlert }: { body: CollectionWithSubCaseResponse } = await supertest + .post(getCaseCommentsUrl(caseInfo.id)) + .query({ subCaseID: caseInfo.subCase!.id }) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + + const { body }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql( + commentsResp({ + comments: [ + { comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }, + { + comment: postCommentAlertReq, + id: singleAlert.subCase!.comments![1].id, + }, + ], + associationType: AssociationType.subCase, + }) + ); + + expect(removeServerGeneratedPropertiesFromSubCase(body)).to.eql( + subCaseResp({ id: body.id, totalComment: 2, totalAlerts: 3 }) + ); + }); + + it('unhappy path - 404s when case is not there', async () => { + await supertest + .get(getSubCaseDetailsUrl('fake-case-id', 'fake-sub-case-id')) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts new file mode 100644 index 0000000000000..66422724b5677 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../../plugins/case/common/constants'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + setStatus, +} from '../../../../common/lib/utils'; +import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers'; +import { CaseStatuses, SubCaseResponse } from '../../../../../../plugins/case/common/api'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('patch_sub_cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should update the status of a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + await setStatus({ + supertest, + cases: [ + { + id: caseInfo.subCase!.id, + version: caseInfo.subCase!.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }); + const { body: subCase }: { body: SubCaseResponse } = await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id)) + .expect(200); + + expect(subCase.status).to.eql(CaseStatuses['in-progress']); + }); + + it('404s when sub case id is invalid', async () => { + await supertest + .patch(`${SUB_CASES_PATCH_DEL_URL}`) + .set('kbn-xsrf', 'true') + .send({ + subCases: [ + { + id: 'fake-id', + version: 'blah', + status: CaseStatuses.open, + }, + ], + }) + .expect(404); + }); + + it('406s when updating invalid fields for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + + await supertest + .patch(`${SUB_CASES_PATCH_DEL_URL}`) + .set('kbn-xsrf', 'true') + .send({ + subCases: [ + { + id: caseInfo.subCase!.id, + version: caseInfo.subCase!.version, + type: 'blah', + }, + ], + }) + .expect(406); + }); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index 1cbf79cb3326c..b771da84d4360 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -10,7 +10,12 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../../plugins/case/common/constants'; import { CommentType } from '../../../../../../plugins/case/common/api'; -import { defaultUser, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + userActionPostResp, + defaultUser, + postCaseReq, + postCommentUserReq, +} from '../../../../common/lib/mock'; import { deleteCases, deleteCasesUserActions, @@ -73,7 +78,7 @@ export default ({ getService }: FtrProviderContext): void => { ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null); - expect(body[0].new_value).to.eql(JSON.stringify(postCaseReq)); + expect(JSON.parse(body[0].new_value)).to.eql(userActionPostResp); }); it(`on close case, user action: 'update' should be called with actionFields: ['status']`, async () => { @@ -147,10 +152,18 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(2); expect(body[1].action_field).to.eql(['connector']); expect(body[1].action).to.eql('update'); - expect(body[1].old_value).to.eql(`{"id":"none","name":"none","type":".none","fields":null}`); - expect(body[1].new_value).to.eql( - `{"id":"123","name":"Connector","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}}` - ); + expect(JSON.parse(body[1].old_value)).to.eql({ + id: 'none', + name: 'none', + type: '.none', + fields: null, + }); + expect(JSON.parse(body[1].new_value)).to.eql({ + id: '123', + name: 'Connector', + type: '.jira', + fields: { issueType: 'Task', priority: 'High', parent: null }, + }); }); it(`on update tags, user action: 'add' and 'delete' should be called with actionFields: ['tags']`, async () => { @@ -284,7 +297,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(body[1].action_field).to.eql(['comment']); expect(body[1].action).to.eql('create'); expect(body[1].old_value).to.eql(null); - expect(body[1].new_value).to.eql(JSON.stringify(postCommentUserReq)); + expect(JSON.parse(body[1].new_value)).to.eql(postCommentUserReq); }); it(`on update comment, user action: 'update' should be called with actionFields: ['comments']`, async () => { @@ -317,13 +330,11 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(3); expect(body[2].action_field).to.eql(['comment']); expect(body[2].action).to.eql('update'); - expect(body[2].old_value).to.eql(JSON.stringify(postCommentUserReq)); - expect(body[2].new_value).to.eql( - JSON.stringify({ - comment: newComment, - type: CommentType.user, - }) - ); + expect(JSON.parse(body[2].old_value)).to.eql(postCommentUserReq); + expect(JSON.parse(body[2].new_value)).to.eql({ + comment: newComment, + type: CommentType.user, + }); }); it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index 302c3a0423bed..01dd6ed5404c2 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -17,10 +17,21 @@ import { removeServerGeneratedPropertiesFromCase, removeServerGeneratedPropertiesFromComments, } from '../../../common/lib/mock'; +import { + createRule, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsByIds, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, +} from '../../../../detection_engine_api_integration/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('case_connector', () => { let createdActionId = ''; @@ -682,47 +693,80 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - // TODO: Remove it when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it('should fail adding a comment of type alert', async () => { - const { body: createdAction } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A case connector', - actionTypeId: '.case', - config: {}, - }) - .expect(200); + describe('adding alerts using a connector', () => { + beforeEach(async () => { + await esArchiver.load('auditbeat/hosts'); + await createSignalsIndex(supertest); + }); - createdActionId = createdAction.id; + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('auditbeat/hosts'); + }); - const caseRes = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); + it('should add a comment of type alert', async () => { + // TODO: don't do all this stuff + const rule = getRuleForSignalTesting(['auditbeat-*']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + const alert = signals.hits.hits[0]; + + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); - const params = { - subAction: 'addComment', - subActionParams: { - caseId: caseRes.body.id, - comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, - }, - }; + createdActionId = createdAction.id; - const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) - .set('kbn-xsrf', 'foo') - .send({ params }) - .expect(200); + const caseRes = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); - expect(caseConnector.body).to.eql({ - status: 'error', - actionId: createdActionId, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.type]: expected value to equal [user]', - retry: false, + const params = { + subAction: 'addComment', + subActionParams: { + caseId: caseRes.body.id, + comment: { alertId: alert._id, index: alert._index, type: CommentType.alert }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body.status).to.eql('ok'); + + const { body } = await supertest + .get(`${CASES_URL}/${caseRes.body.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(body); + const comments = removeServerGeneratedPropertiesFromComments(data.comments ?? []); + expect({ ...data, comments }).to.eql({ + ...postCaseResp(caseRes.body.id), + comments, + totalAlerts: 1, + totalComment: 1, + updated_by: { + email: null, + full_name: null, + username: null, + }, + }); }); }); @@ -791,7 +835,7 @@ export default ({ getService }: FtrProviderContext): void => { }, }; - for (const attribute of ['alertId', 'index']) { + for (const attribute of ['blah', 'bogus']) { const caseConnector = await supertest .post(`/api/actions/action/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') @@ -805,11 +849,10 @@ export default ({ getService }: FtrProviderContext): void => { }, }) .expect(200); - expect(caseConnector.body).to.eql({ status: 'error', actionId: createdActionId, - message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing`, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]\n - [subActionParams.comment.2.type]: expected value to equal [generated_alert]`, retry: false, }); } diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts index f547b78102658..837e6503084a7 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -16,6 +16,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./cases/comments/delete_comment')); loadTestFile(require.resolve('./cases/comments/find_comments')); loadTestFile(require.resolve('./cases/comments/get_comment')); + loadTestFile(require.resolve('./cases/comments/get_all_comments')); loadTestFile(require.resolve('./cases/comments/patch_comment')); loadTestFile(require.resolve('./cases/comments/post_comment')); loadTestFile(require.resolve('./cases/delete_cases')); @@ -33,6 +34,10 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./configure/patch_configure')); loadTestFile(require.resolve('./configure/post_configure')); loadTestFile(require.resolve('./connectors/case')); + loadTestFile(require.resolve('./cases/sub_cases/patch_sub_cases')); + loadTestFile(require.resolve('./cases/sub_cases/delete_sub_cases')); + loadTestFile(require.resolve('./cases/sub_cases/get_sub_case')); + loadTestFile(require.resolve('./cases/sub_cases/find_sub_cases')); // Migrations loadTestFile(require.resolve('./cases/migrations')); diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index 04f0812553456..2f4fa1b30f564 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -5,6 +5,12 @@ * 2.0. */ +import { + CommentSchemaType, + ContextTypeGeneratedAlertType, + isCommentGeneratedAlert, + transformConnectorComment, +} from '../../../../plugins/case/server/connectors'; import { CasePostRequest, CaseResponse, @@ -15,7 +21,15 @@ import { CommentRequestAlertType, CommentType, CaseStatuses, + CaseType, + CaseClientPostRequest, + SubCaseResponse, + AssociationType, + CollectionWithSubCaseResponse, + SubCasesFindResponse, + CommentRequest, } from '../../../../plugins/case/common/api'; + export const defaultUser = { email: null, full_name: null, username: 'elastic' }; export const postCaseReq: CasePostRequest = { description: 'This is a brand new case of a bad meanie defacing data', @@ -32,6 +46,22 @@ export const postCaseReq: CasePostRequest = { }, }; +/** + * The fields for creating a collection style case. + */ +export const postCollectionReq: CasePostRequest = { + ...postCaseReq, + type: CaseType.collection, +}; + +/** + * This is needed because the post api does not allow specifying the case type. But the response will include the type. + */ +export const userActionPostResp: CaseClientPostRequest = { + ...postCaseReq, + type: CaseType.individual, +}; + export const postCommentUserReq: CommentRequestUserType = { comment: 'This is a cool comment', type: CommentType.user, @@ -43,6 +73,12 @@ export const postCommentAlertReq: CommentRequestAlertType = { type: CommentType.alert, }; +export const postCommentGenAlertReq: ContextTypeGeneratedAlertType = { + alerts: [{ _id: 'test-id' }, { _id: 'test-id2' }], + index: 'test-index', + type: CommentType.generatedAlert, +}; + export const postCaseResp = ( id: string, req: CasePostRequest = postCaseReq @@ -50,7 +86,9 @@ export const postCaseResp = ( ...req, id, comments: [], + totalAlerts: 0, totalComment: 0, + type: req.type ?? CaseType.individual, closed_by: null, created_by: defaultUser, external_service: null, @@ -58,6 +96,100 @@ export const postCaseResp = ( updated_by: null, }); +interface CommentRequestWithID { + id: string; + comment: CommentSchemaType | CommentRequest; +} + +export const commentsResp = ({ + comments, + associationType, +}: { + comments: CommentRequestWithID[]; + associationType: AssociationType; +}): Array> => { + return comments.map(({ comment, id }) => { + const baseFields = { + id, + created_by: defaultUser, + pushed_at: null, + pushed_by: null, + updated_by: null, + }; + + if (isCommentGeneratedAlert(comment)) { + return { + associationType, + ...transformConnectorComment(comment), + ...baseFields, + }; + } else { + return { + associationType, + ...comment, + ...baseFields, + }; + } + }); +}; + +export const subCaseResp = ({ + id, + totalAlerts, + totalComment, + status = CaseStatuses.open, +}: { + id: string; + status?: CaseStatuses; + totalAlerts: number; + totalComment: number; +}): Partial => ({ + status, + id, + totalAlerts, + totalComment, + closed_by: null, + created_by: defaultUser, + updated_by: defaultUser, +}); + +interface FormattedCollectionResponse { + caseInfo: Partial; + subCase?: Partial; + comments?: Array>; +} + +export const formatCollectionResponse = ( + caseInfo: CollectionWithSubCaseResponse +): FormattedCollectionResponse => { + return { + caseInfo: removeServerGeneratedPropertiesFromCaseCollection(caseInfo), + subCase: removeServerGeneratedPropertiesFromSubCase(caseInfo.subCase), + comments: removeServerGeneratedPropertiesFromComments( + caseInfo.subCase?.comments ?? caseInfo.comments + ), + }; +}; + +export const removeServerGeneratedPropertiesFromSubCase = ( + subCase: Partial | undefined +): Partial | undefined => { + if (!subCase) { + return; + } + // eslint-disable-next-line @typescript-eslint/naming-convention + const { closed_at, created_at, updated_at, version, comments, ...rest } = subCase; + return rest; +}; + +export const removeServerGeneratedPropertiesFromCaseCollection = ( + config: Partial +): Partial => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { closed_at, created_at, updated_at, version, subCase, ...rest } = config; + return rest; +}; + export const removeServerGeneratedPropertiesFromCase = ( config: Partial ): Partial => { @@ -67,21 +199,30 @@ export const removeServerGeneratedPropertiesFromCase = ( }; export const removeServerGeneratedPropertiesFromComments = ( - comments: CommentResponse[] -): Array> => { - return comments.map((comment) => { + comments: CommentResponse[] | undefined +): Array> | undefined => { + return comments?.map((comment) => { // eslint-disable-next-line @typescript-eslint/naming-convention const { created_at, updated_at, version, ...rest } = comment; return rest; }); }; -export const findCasesResp: CasesFindResponse = { +const findCommon = { page: 1, per_page: 20, total: 0, - cases: [], count_open_cases: 0, count_closed_cases: 0, count_in_progress_cases: 0, }; + +export const findCasesResp: CasesFindResponse = { + ...findCommon, + cases: [], +}; + +export const findSubCasesResp: SubCasesFindResponse = { + ...findCommon, + subCases: [], +}; 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 27a49c3f05869..048c5c5d84098 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -4,14 +4,197 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import expect from '@kbn/expect'; import { Client } from '@elastic/elasticsearch'; +import * as st from 'supertest'; +import supertestAsPromised from 'supertest-as-promised'; +import { CASES_URL, SUB_CASES_PATCH_DEL_URL } from '../../../../plugins/case/common/constants'; import { CasesConfigureRequest, CasesConfigureResponse, CaseConnector, ConnectorTypes, + CasePostRequest, + CollectionWithSubCaseResponse, + SubCasesFindResponse, + CaseStatuses, + SubCasesResponse, + CasesResponse, } from '../../../../plugins/case/common/api'; +import { postCollectionReq, postCommentGenAlertReq } from './mock'; +import { getSubCasesUrl } from '../../../../plugins/case/common/api/helpers'; +import { ContextTypeGeneratedAlertType } from '../../../../plugins/case/server/connectors'; + +interface SetStatusCasesParams { + id: string; + version: string; + status: CaseStatuses; +} + +/** + * Sets the status of some cases or sub cases. The cases field must be all of one type. + */ +export const setStatus = async ({ + supertest, + cases, + type, +}: { + supertest: st.SuperTest; + cases: SetStatusCasesParams[]; + type: 'case' | 'sub_case'; +}): Promise => { + const url = type === 'case' ? CASES_URL : SUB_CASES_PATCH_DEL_URL; + const patchFields = type === 'case' ? { cases } : { subCases: cases }; + const { body }: { body: CasesResponse | SubCasesResponse } = await supertest + .patch(url) + .set('kbn-xsrf', 'true') + .send(patchFields) + .expect(200); + return body; +}; + +/** + * Variable to easily access the default comment for the createSubCase function. + */ +export const defaultCreateSubComment = postCommentGenAlertReq; + +/** + * Variable to easily access the default comment for the createSubCase function. + */ +export const defaultCreateSubPost = postCollectionReq; + +/** + * Response structure for the createSubCase and createSubCaseComment functions. + */ +export interface CreateSubCaseResp { + newSubCaseInfo: CollectionWithSubCaseResponse; + modifiedSubCases?: SubCasesResponse; +} + +/** + * Creates a sub case using the actions API. If a caseID isn't passed in then it will create + * the collection as well. To create a sub case a comment must be created so it uses a default + * generated alert style comment which can be overridden. + */ +export const createSubCase = async (args: { + supertest: st.SuperTest; + comment?: ContextTypeGeneratedAlertType; + caseID?: string; + caseInfo?: CasePostRequest; + actionID?: string; +}): Promise => { + return createSubCaseComment({ ...args, forceNewSubCase: true }); +}; + +/** + * Add case as a connector + */ +export const createCaseAction = async (supertest: st.SuperTest) => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + return createdAction.id; +}; + +/** + * Remove a connector + */ +export const deleteCaseAction = async ( + supertest: st.SuperTest, + id: string +) => { + await supertest.delete(`/api/actions/action/${id}`).set('kbn-xsrf', 'foo'); +}; + +/** + * Creates a sub case using the actions APIs. This will handle forcing a creation of a new sub case even if one exists + * if the forceNewSubCase parameter is set to true. + */ +export const createSubCaseComment = async ({ + supertest, + caseID, + comment = defaultCreateSubComment, + caseInfo = defaultCreateSubPost, + // if true it will close any open sub cases and force a new sub case to be opened + forceNewSubCase = false, + actionID, +}: { + supertest: st.SuperTest; + comment?: ContextTypeGeneratedAlertType; + caseID?: string; + caseInfo?: CasePostRequest; + forceNewSubCase?: boolean; + actionID?: string; +}): Promise => { + let actionIDToUse: string; + + if (actionID === undefined) { + actionIDToUse = await createCaseAction(supertest); + } else { + actionIDToUse = actionID; + } + + let collectionID: string; + + if (!caseID) { + collectionID = ( + await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(caseInfo).expect(200) + ).body.id; + } else { + collectionID = caseID; + } + + let closedSubCases: SubCasesResponse | undefined; + if (forceNewSubCase) { + const { body: subCasesResp }: { body: SubCasesFindResponse } = await supertest + .get(`${getSubCasesUrl(collectionID)}/_find`) + .expect(200); + + const nonClosed = subCasesResp.subCases.filter( + (subCase) => subCase.status !== CaseStatuses.closed + ); + if (nonClosed.length > 0) { + // mark the sub case as closed so a new sub case will be created on the next comment + closedSubCases = ( + await supertest + .patch(SUB_CASES_PATCH_DEL_URL) + .set('kbn-xsrf', 'true') + .send({ + subCases: nonClosed.map((subCase) => ({ + id: subCase.id, + version: subCase.version, + status: CaseStatuses.closed, + })), + }) + .expect(200) + ).body; + } + } + + const caseConnector = await supertest + .post(`/api/actions/action/${actionIDToUse}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'addComment', + subActionParams: { + caseId: collectionID, + comment, + }, + }, + }) + .expect(200); + + expect(caseConnector.body.status).to.eql('ok'); + return { newSubCaseInfo: caseConnector.body.data, modifiedSubCases: closedSubCases }; +}; export const getConfiguration = ({ id = 'none', @@ -104,6 +287,16 @@ export const removeServerGeneratedPropertiesFromConfigure = ( return rest; }; +export const deleteAllCaseItems = async (es: Client) => { + await Promise.all([ + deleteCases(es), + deleteSubCases(es), + deleteCasesUserActions(es), + deleteComments(es), + deleteConfiguration(es), + ]); +}; + export const deleteCasesUserActions = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana', @@ -124,6 +317,20 @@ export const deleteCases = async (es: Client): Promise => { }); }; +/** + * Deletes all sub cases in the .kibana index. This uses ES to perform the delete and does + * not go through the case API. + */ +export const deleteSubCases = async (es: Client): Promise => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:cases-sub-case', + wait_for_completion: true, + refresh: true, + body: {}, + }); +}; + export const deleteComments = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana',