diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index bea6419d59483..2dc6dd4fde4d9 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -33,6 +33,8 @@ export const CommentAttributesBasicRt = rt.type({ updated_by: rt.union([UserRT, rt.null]), }); +export const GeneratedAlertRequestTypeField = 'generated_alert_request'; + export enum CommentType { user = 'user', alert = 'alert', @@ -44,34 +46,50 @@ export const ContextTypeUserRt = rt.type({ type: rt.literal(CommentType.user), }); -export const ContextTypeAlertRt = rt.type({ +/** + * This defines the structure of how alerts (generated or user attached) are stored in saved objects documents. + */ +export const AlertCommentAttributesRt = 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, }); +/** + * This defines the structure of an alert attached by a user. + */ +export const AlertCommentRequestRt = rt.type({ + type: rt.literal(CommentType.alert), + alertId: rt.union([rt.array(rt.string), rt.string]), + index: rt.string, +}); + const AlertIDRt = rt.type({ _id: rt.string, }); -export const ContextTypeGeneratedAlertRt = rt.type({ - type: rt.literal(CommentType.generatedAlert), +/** + * This defines the structure of generated alerts attached by a detections rule. + */ +export const GeneratedAlertCommentRequestRt = rt.type({ + type: rt.literal(GeneratedAlertRequestTypeField), alerts: rt.union([rt.array(AlertIDRt), AlertIDRt]), index: rt.string, }); const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); -const AttributesTypeAlertsRt = rt.intersection([ContextTypeAlertRt, CommentAttributesBasicRt]); +const AttributesTypeAlertsRt = rt.intersection([ + AlertCommentAttributesRt, + CommentAttributesBasicRt, +]); const CommentAttributesRt = rt.union([AttributesTypeUserRt, AttributesTypeAlertsRt]); -const ContextBasicRt = rt.union([ +export const CommentRequestRt = rt.union([ ContextTypeUserRt, - ContextTypeAlertRt, - ContextTypeGeneratedAlertRt, + AlertCommentRequestRt, + GeneratedAlertCommentRequestRt, ]); -export const CommentRequestRt = ContextBasicRt; - export const CommentResponseRt = rt.intersection([ CommentAttributesRt, rt.type({ @@ -90,12 +108,14 @@ export const CommentResponseTypeAlertsRt = rt.intersection([ export const AllCommentsResponseRT = rt.array(CommentResponseRt); +const CommentPatchRequestTypesRt = rt.union([ContextTypeUserRt, AlertCommentAttributesRt]); + export const CommentPatchRequestRt = rt.intersection([ /** * Partial updates are not allowed. * We want to prevent the user for changing the type without removing invalid fields. */ - rt.union([ContextTypeUserRt, ContextTypeAlertRt]), + CommentPatchRequestTypesRt, rt.type({ id: rt.string, version: rt.string }), ]); @@ -106,7 +126,10 @@ 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(AlertCommentAttributesRt.props), + ]), rt.partial(CommentAttributesBasicRt.props), ]); @@ -119,7 +142,6 @@ export const CommentsResponseRt = rt.type({ export const AllCommentsResponseRt = rt.array(CommentResponseRt); -export type AttributesTypeAlerts = rt.TypeOf; export type CommentAttributes = rt.TypeOf; export type CommentRequest = rt.TypeOf; export type CommentResponse = rt.TypeOf; @@ -129,11 +151,26 @@ 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; + +/** + * This is different than a CommentRequest because a patch request only allows the alert and user types to be patched. + * A CommentRequest allows alerts, user, and generated_alerts + */ +export type CommentPatchRequestTypes = rt.TypeOf; + +/** + * This type includes the index, alertIds, and the basic attributes (who added it, when etc) + */ +export type AttributesTypeAlerts = rt.TypeOf; +/** + * This type includes only the index, alertIds, it does not include the basic attributes. + */ +export type AttributesTypeAlertsWithoutBasic = rt.TypeOf; /** * This type represents a generated alert (or alerts) from a rule. The difference between this and a user alert * is that this format expects the included alerts to have the structure { _id: string }. When it is saved the outer * object will be stripped off and the _id will be stored in the alertId field. */ -export type CommentRequestGeneratedAlertType = rt.TypeOf; +export type CommentRequestGeneratedAlertType = rt.TypeOf; 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 a142079a7ccc8..cb18bd4fc16e3 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -17,7 +17,6 @@ interface UpdateAlertsStatusArgs { scopedClusterClient: ElasticsearchClient; } -// TODO: remove this file export const updateAlertsStatus = async ({ alertsService, ids, diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index d3aa661c5fb51..ee47c59072fdd 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -10,7 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; +import { SavedObjectsClientContract } from 'src/core/server'; import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; import { @@ -21,6 +21,7 @@ import { CaseClientPostRequestRt, CasePostRequest, CaseType, + User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { @@ -37,7 +38,7 @@ import { interface CreateCaseArgs { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; - request: KibanaRequest; + user: User; savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; theCase: CasePostRequest; @@ -48,7 +49,7 @@ export const create = async ({ caseService, caseConfigureService, userActionService, - request, + user, theCase, }: CreateCaseArgs): Promise => { // default to an individual case if the type is not defined. @@ -60,7 +61,7 @@ export const create = async ({ ); // 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/push.ts b/x-pack/plugins/case/server/client/cases/push.ts index effa57bc32880..881e586b5a4a3 100644 --- a/x-pack/plugins/case/server/client/cases/push.ts +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -7,13 +7,12 @@ import Boom, { isBoom, Boom as BoomType } from '@hapi/boom'; import { - KibanaRequest, SavedObjectsBulkUpdateResponse, SavedObjectsClientContract, SavedObjectsUpdateResponse, } from 'kibana/server'; -import { ActionsClient } from '../../../../actions/server'; -import { flattenCaseSavedObject, getAlertIds } from '../../routes/api/utils'; +import { ActionResult, ActionsClient } from '../../../../actions/server'; +import { flattenCaseSavedObject, getAlertIndicesAndIDs } from '../../routes/api/utils'; import { ActionConnector, @@ -23,16 +22,18 @@ import { ExternalServiceResponse, ESCaseAttributes, CommentAttributes, + CaseUserActionsResponse, + User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; -import { createIncident, getCommentContextFromAttributes, isCommentAlertType } from './utils'; +import { createIncident, getCommentContextFromAttributes } from './utils'; import { CaseConfigureServiceSetup, CaseServiceSetup, CaseUserActionServiceSetup, } from '../../services'; -import { CaseClientImpl } from '../client'; +import { CaseClientHandler } from '../client'; const createError = (e: Error | BoomType, message: string): Error | BoomType => { if (isBoom(e)) { @@ -44,20 +45,15 @@ const createError = (e: Error | BoomType, message: string): Error | BoomType => return Error(message); }; -interface AlertInfo { - ids: string[]; - indices: Set; -} - interface PushParams { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; caseConfigureService: CaseConfigureServiceSetup; userActionService: CaseUserActionServiceSetup; - request: KibanaRequest; + user: User; caseId: string; connectorId: string; - caseClient: CaseClientImpl; + caseClient: CaseClientHandler; actionsClient: ActionsClient; } @@ -66,16 +62,16 @@ export const push = async ({ caseService, caseConfigureService, userActionService, - request, caseClient, actionsClient, connectorId, 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; @@ -98,16 +94,7 @@ export const push = async ({ ); } - const { ids, indices }: AlertInfo = theCase?.comments - ?.filter(isCommentAlertType) - .reduce( - (acc, comment) => { - acc.ids.push(...getAlertIds(comment)); - acc.indices.add(comment.index); - return acc; - }, - { ids: [], indices: new Set() } - ) ?? { ids: [], indices: new Set() }; + const { ids, indices } = getAlertIndicesAndIDs(theCase?.comments); try { alerts = await caseClient.getAlerts({ @@ -160,14 +147,12 @@ export const push = async ({ /* 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 }), + [myCase, myCaseConfigure, comments] = await Promise.all([ caseService.getCase({ client: savedObjectsClient, id: caseId, 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 16c83b0ec1f61..53e233c74deb4 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -404,7 +404,18 @@ 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 }); @@ -531,24 +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(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', 'test-index-2']), + 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', 'test-index-2']), + indices: new Set(['test-index-2']), }); }); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index a1646c7f12866..915746e270ade 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -11,12 +11,16 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { - KibanaRequest, SavedObject, SavedObjectsClientContract, SavedObjectsFindResponse, + SavedObjectsFindResult, } from 'kibana/server'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { + AlertInfo, + flattenCaseSavedObject, + isGenOrAlertCommentAttributes, +} from '../../routes/api/utils'; import { throwErrors, @@ -31,6 +35,9 @@ import { ESCaseAttributes, CaseType, CasesPatchRequest, + AssociationType, + CommentAttributes, + User, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { @@ -39,8 +46,13 @@ import { } from '../../routes/api/cases/helpers'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; -import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; +import { + CASE_COMMENT_SAVED_OBJECT, + CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; import { CaseClientImpl } from '..'; +import { addAlertInfoToStatusMap } from '../../common'; /** * Throws an error if any of the requests attempt to update a collection style cases' status field. @@ -130,11 +142,187 @@ async function throwIfInvalidUpdateOfTypeWithAlerts({ } } +/** + * 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 ( + isGenOrAlertCommentAttributes(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: CaseClientImpl; +}) { + /** + * 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 (isGenOrAlertCommentAttributes(alertComment.attributes)) { + const status = getSyncStatusForComment({ + alertComment, + casesToSyncToStatus, + subCasesToStatus, + }); + + 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, + }); + } + } +} + interface UpdateArgs { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; - request: KibanaRequest; + user: User; caseClient: CaseClientImpl; cases: CasesPatchRequest; } @@ -143,7 +331,7 @@ export const update = async ({ savedObjectsClient, caseService, userActionService, - request, + user, caseClient, cases, }: UpdateArgs): Promise => { @@ -220,7 +408,7 @@ export const update = async ({ }); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); + const { username, full_name, email } = user; const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ client: savedObjectsClient, @@ -279,59 +467,15 @@ export const update = async ({ ); }); - for (const theCase of [...casesWithSyncSettingChangedToOn, ...casesWithStatusChangedAndSynced]) { - const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); - const totalComments = await caseService.getAllCaseComments({ - client: savedObjectsClient, - id: theCase.id, - options: { - fields: [], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, - page: 1, - perPage: 1, - }, - }); - - // TODO: if a collection's sync settings are change we need to get all the sub cases and sync their alerts according - // to the SUB CASE status not the collection's status - // I think what we can do is get all comments for a collection's sub cases in a single call, then group the alerts by - // sub case ID, then query for all those sub cases that we need, grab their status, build another map of status - // to {ids: string[], indices: Set} then iterate over the map and perform a updateAlertsStatus - // for each group of alerts for the 3 statuses. - - const caseComments = (await caseService.getAllCaseComments({ - client: savedObjectsClient, - id: theCase.id, - options: { - fields: [], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, - page: 1, - perPage: totalComments.total, - }, - // The filter guarantees that the comments will be of type alert - })) as SavedObjectsFindResponse<{ alertId: string | string[]; index: string }>; - - // TODO: comment about why we need this (aka alerts might come from different indices? so dedup them) - const idsAndIndices = caseComments.saved_objects.reduce( - (acc: { ids: string[]; indices: Set }, comment) => { - const alertId = comment.attributes.alertId; - const ids = Array.isArray(alertId) ? alertId : [alertId]; - acc.ids.push(...ids); - acc.indices.add(comment.attributes.index); - return acc; - }, - { ids: [], indices: new Set() } - ); - - if (idsAndIndices.ids.length > 0) { - caseClient.updateAlertsStatus({ - ids: idsAndIndices.ids, - // Either there is a status update or the syncAlerts got turned on. - status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, - indices: idsAndIndices.indices, - }); - } - } + // Update the alert's status to match any case status or sync settings changes + await updateAlerts({ + casesWithStatusChangedAndSynced, + casesWithSyncSettingChangedToOn, + caseService, + client: savedObjectsClient, + caseClient, + casesMap, + }); const returnUpdatedCase = myCases.saved_objects .filter((myCase) => diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index 78bdc6d282c69..2b0dcebf143d8 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -19,7 +19,7 @@ import { ConnectorTypes, CommentAttributes, CommentRequestUserType, - CommentRequestAlertType, + AttributesTypeAlertsWithoutBasic, } from '../../../common/api'; import { ActionsClient } from '../../../../actions/server'; import { externalServiceFormatters, FormatterConnectorTypes } from '../../connectors'; @@ -38,7 +38,7 @@ import { TransformerArgs, TransformFieldsArgs, } from './types'; -import { getAlertIds } from '../../routes/api/utils'; +import { getAlertIdsFromAttributes } from '../../routes/api/utils'; export const getLatestPushInfo = ( connectorId: string, @@ -68,7 +68,7 @@ const getCommentContent = (comment: CommentResponse): string => { if (comment.type === CommentType.user) { return comment.comment; } else if (comment.type === CommentType.alert || comment.type === CommentType.generatedAlert) { - const ids = getAlertIds(comment); + const ids = getAlertIdsFromAttributes(comment); return `Alert with ids ${ids.join(', ')} added to case`; } @@ -301,7 +301,7 @@ export const isCommentAlertType = ( export const getCommentContextFromAttributes = ( attributes: CommentAttributes -): CommentRequestUserType | CommentRequestAlertType => { +): CommentRequestUserType | AttributesTypeAlertsWithoutBasic => { switch (attributes.type) { case CommentType.user: return { diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts index 88c854ae89245..c684548decbe6 100644 --- a/x-pack/plugins/case/server/client/client.ts +++ b/x-pack/plugins/case/server/client/client.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ElasticsearchClient, KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; +import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { CaseClientFactoryArguments, CaseClient, @@ -31,31 +31,31 @@ import { CaseUserActionServiceSetup, AlertServiceContract, } from '../services'; -import { CasesPatchRequest, CasePostRequest } from '../../common/api'; +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'; -// TODO: rename -export class CaseClientImpl implements CaseClient { +/** + * 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 request: KibanaRequest; + private readonly user: User; private readonly _savedObjectsClient: SavedObjectsClientContract; private readonly _userActionService: CaseUserActionServiceSetup; private readonly _alertsService: AlertServiceContract; - // TODO: refactor so these are created in the constructor instead of passed in constructor(clientArgs: CaseClientFactoryArguments) { this._scopedClusterClient = clientArgs.scopedClusterClient; this._caseConfigureService = clientArgs.caseConfigureService; this._caseService = clientArgs.caseService; this._connectorMappingsService = clientArgs.connectorMappingsService; - // TODO: extract this out so we just pass in the user information - this.request = clientArgs.request; + this.user = clientArgs.user; this._savedObjectsClient = clientArgs.savedObjectsClient; this._userActionService = clientArgs.userActionService; this._alertsService = clientArgs.alertsService; @@ -67,7 +67,7 @@ export class CaseClientImpl implements CaseClient { caseService: this._caseService, caseConfigureService: this._caseConfigureService, userActionService: this._userActionService, - request: this.request, + user: this.user, theCase: caseInfo, }); } @@ -77,7 +77,7 @@ export class CaseClientImpl implements CaseClient { savedObjectsClient: this._savedObjectsClient, caseService: this._caseService, userActionService: this._userActionService, - request: this.request, + user: this.user, cases, caseClient: this, }); @@ -91,7 +91,7 @@ export class CaseClientImpl implements CaseClient { caseClient: this, caseId, comment, - request: this.request, + user: this.user, }); } @@ -146,7 +146,7 @@ export class CaseClientImpl implements CaseClient { savedObjectsClient: this._savedObjectsClient, caseService: this._caseService, userActionService: this._userActionService, - request: this.request, + user: this.user, caseClient: this, caseConfigureService: this._caseConfigureService, }); diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 5b7c409ea51e5..7fb7193d8a114 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -10,8 +10,12 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { KibanaRequest, SavedObject, SavedObjectsClientContract } from 'src/core/server'; -import { decodeComment, getAlertIds, isGeneratedAlertContext } from '../../routes/api/utils'; +import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; +import { + decodeCommentRequest, + getAlertIdsFromRequest, + isCommentRequestTypeGenAlert, +} from '../../routes/api/utils'; import { throwErrors, @@ -22,9 +26,10 @@ import { SubCaseAttributes, CommentRequest, CollectionWithSubCaseResponse, - ContextTypeGeneratedAlertRt, + GeneratedAlertCommentRequestRt, CommentRequestGeneratedAlertType, User, + GeneratedAlertRequestTypeField, } from '../../../common/api'; import { buildCaseUserActionItem, @@ -96,11 +101,11 @@ const addGeneratedAlerts = async ({ comment, }: AddCommentFromRuleArgs): Promise => { const query = pipe( - ContextTypeGeneratedAlertRt.decode(comment), + GeneratedAlertCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); - decodeComment(comment); + decodeCommentRequest(comment); const createdDate = new Date().toISOString(); const caseInfo = await caseService.getCase({ @@ -109,7 +114,7 @@ const addGeneratedAlerts = async ({ }); if ( - query.type === CommentType.generatedAlert && + query.type === GeneratedAlertRequestTypeField && caseInfo.attributes.type !== CaseType.collection ) { throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); @@ -147,10 +152,10 @@ const addGeneratedAlerts = async ({ newComment.attributes.type === CommentType.generatedAlert) && caseInfo.attributes.settings.syncAlerts ) { - const ids = getAlertIds(query); + const ids = getAlertIdsFromRequest(query); await caseClient.updateAlertsStatus({ ids, - status: caseInfo.attributes.status, + status: subCase.attributes.status, indices: new Set([newComment.attributes.index]), }); } @@ -221,24 +226,24 @@ interface AddCommentArgs { savedObjectsClient: SavedObjectsClientContract; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; - request: KibanaRequest; + user: User; } export const addComment = async ({ savedObjectsClient, caseService, userActionService, - request, caseClient, caseId, comment, + user, }: AddCommentArgs): Promise => { const query = pipe( CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); - if (isGeneratedAlertContext(comment)) { + if (isCommentRequestTypeGenAlert(comment)) { return addGeneratedAlerts({ caseId, comment, @@ -249,13 +254,13 @@ export const addComment = async ({ }); } - decodeComment(comment); + 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 } = await caseService.getUser({ request }); + const { username, full_name, email } = user; const userInfo: User = { username, full_name, @@ -269,7 +274,7 @@ export const addComment = async ({ }); if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { - const ids = getAlertIds(query); + const ids = getAlertIdsFromRequest(query); await caseClient.updateAlertsStatus({ ids, status: updatedCase.status, diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 1545bc6b1249f..8a085bf29f214 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { KibanaRequest } from 'kibana/server'; import { elasticsearchServiceMock, savedObjectsClientMock, } from '../../../../../src/core/server/mocks'; +import { nullUser } from '../common'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -19,7 +19,7 @@ import { } from '../services/mocks'; jest.mock('./client'); -import { CaseClientImpl } from './client'; +import { CaseClientHandler } from './client'; import { createExternalCaseClient } from './index'; const esClient = elasticsearchServiceMock.createElasticsearchClient(); @@ -27,7 +27,6 @@ const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); const caseService = createCaseServiceMock(); const connectorMappingsService = connectorMappingsServiceMock(); -const request = {} as KibanaRequest; const savedObjectsClient = savedObjectsClientMock.create(); const userActionService = createUserActionServiceMock(); @@ -39,10 +38,10 @@ describe('createExternalCaseClient()', () => { caseConfigureService, caseService, connectorMappingsService, - request, + user: nullUser, savedObjectsClient, userActionService, }); - expect(CaseClientImpl).toHaveBeenCalledTimes(1); + 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 06f0dfd40ee7d..6c8cf53b2ae27 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -6,28 +6,15 @@ */ import { CaseClientFactoryArguments, CaseClient } from './types'; -import { CaseClientImpl } from './client'; +import { CaseClientHandler } from './client'; -export { CaseClientImpl } from './client'; +export { CaseClientHandler as CaseClientImpl } from './client'; export { CaseClient } from './types'; -// TODO: this screws up the mocking because it won't mock out CaseClientImpl's methods -/* export const createExternalCaseClient = ( - clientArgs: CaseClientFactoryArguments -): CaseClientPluginContract => { - const client = new CaseClientImpl(clientArgs); - return { - create: async (args: CaseClientPostRequest) => client.create(args), - addComment: async (args: CaseClientAddComment) => client.addComment(args), - getFields: async (args: ConfigureFields) => client.getFields(args), - getMappings: async (args: MappingsClient) => client.getMappings(args), - update: async (args: CasesPatchRequest) => client.update(args), - updateAlertsStatus: async (args: CaseClientUpdateAlertsStatus) => - client.updateAlertsStatus(args), - }; -};*/ - +/** + * Create a CaseClientHandler to external services (other plugins). + */ export const createExternalCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { - const client = new CaseClientImpl(clientArgs); + 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 f55a7f690a53c..302745913babb 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { KibanaRequest } from 'kibana/server'; import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { AlertServiceContract, @@ -50,7 +49,6 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const esClient = elasticsearchServiceMock.createElasticsearchClient(); // const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); - const request = {} as KibanaRequest; const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); const caseService = new CaseService(log, auth); @@ -73,7 +71,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const caseClient = createExternalCaseClient({ savedObjectsClient, - request, + user: auth.getCurrentUser(), caseService, caseConfigureService, connectorMappingsService, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 9bc13a2cb71e0..a8f64227daf83 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,10 +5,9 @@ * 2.0. */ -import { ElasticsearchClient, KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { - CaseClientPostRequest, CasePostRequest, CaseResponse, CasesPatchRequest, @@ -19,6 +18,7 @@ import { ConnectorMappingsAttributes, GetFieldsResponse, CaseUserActionsResponse, + User, } from '../../common/api'; import { CaseConfigureServiceSetup, @@ -29,16 +29,6 @@ import { import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; import { CaseClientGetAlertsResponse } from './alerts/types'; -// TODO: Remove unused types - -export interface CaseClientCreate { - theCase: CaseClientPostRequest; -} - -export interface CaseClientUpdate { - cases: CasesPatchRequest; -} - export interface CaseClientGet { id: string; includeComments?: boolean; @@ -56,11 +46,6 @@ export interface CaseClientAddComment { comment: CommentRequest; } -export interface CaseClientAddInternalComment { - caseId: string; - comment: CommentRequest; -} - export interface CaseClientUpdateAlertsStatus { ids: string[]; status: CaseStatuses; @@ -87,8 +72,7 @@ export interface CaseClientFactoryArguments { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; connectorMappingsService: ConnectorMappingsServiceSetup; - request: KibanaRequest; - // response: KibanaResponseFactory; + user: User; savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; diff --git a/x-pack/plugins/case/server/common/utils.test.ts b/x-pack/plugins/case/server/common/utils.test.ts index cf1d6e3c91762..d383c09d9a7f9 100644 --- a/x-pack/plugins/case/server/common/utils.test.ts +++ b/x-pack/plugins/case/server/common/utils.test.ts @@ -6,7 +6,13 @@ */ import { SavedObjectsFindResponse } from 'kibana/server'; -import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common/api'; +import { + AssociationType, + CommentAttributes, + CommentRequest, + CommentType, + GeneratedAlertRequestTypeField, +} from '../../common/api'; import { transformNewComment } from '../routes/api/utils'; import { combineFilters, countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; @@ -98,7 +104,7 @@ describe('common utils', () => { { alerts: [{ _id: 'a' }, { _id: 'b' }, { _id: 'c' }], index: '', - type: CommentType.generatedAlert, + type: GeneratedAlertRequestTypeField, }, ], }, diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts index a05278d67d885..f259d5e87736d 100644 --- a/x-pack/plugins/case/server/common/utils.ts +++ b/x-pack/plugins/case/server/common/utils.ts @@ -6,13 +6,45 @@ */ import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; -import { CommentAttributes, CommentType } from '../../common/api'; +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. diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 6017fe312e063..18fac90624554 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -7,7 +7,6 @@ import { curry } from 'lodash'; -import { KibanaRequest } from '../../../../../../src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest } from '../../../common/api'; import { createExternalCaseClient } from '../../client'; @@ -21,6 +20,7 @@ import { import * as i18n from './translations'; import { GetActionTypeParams } from '..'; +import { nullUser } from '../../common'; const supportedSubActions: string[] = ['create', 'update', 'addComment']; @@ -69,13 +69,11 @@ async function executor( let data: CaseExecutorResponse | null = null; const { savedObjectsClient, scopedClusterClient } = services; - // TODO: ??? calling a constructor in a curry generates this error, TypeError: _client.CaseClientImpl is not a constructor - // const caseClient = new CaseClientImpl({ const caseClient = createExternalCaseClient({ savedObjectsClient, scopedClusterClient, - // TODO: refactor this - request: {} as KibanaRequest, + // we might want the user information to be passed as part of the action request + user: nullUser, caseService, caseConfigureService, connectorMappingsService, diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 1010eee652d90..1022f06bb61fc 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { CommentType } from '../../../common/api'; +import { CommentType, GeneratedAlertRequestTypeField } from '../../../common/api'; import { validateConnector } from './validators'; // Reserved for future implementation @@ -25,7 +25,7 @@ const AlertIDSchema = schema.object( ); const ContextTypeAlertGroupSchema = schema.object({ - type: schema.literal(CommentType.generatedAlert), + type: schema.literal(GeneratedAlertRequestTypeField), alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]), index: schema.string(), }); diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 279acf9e8951d..9bf7ca097a561 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -127,10 +127,11 @@ export class CasePlugin { context: CasesRequestHandlerContext, request: KibanaRequest ) => { + const user = await this.caseService!.getUser({ request }); return createExternalCaseClient({ scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: core.savedObjects.getScopedClient(request), - request, + user, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, connectorMappingsService: this.connectorMappingsService!, @@ -165,6 +166,7 @@ export class CasePlugin { }): IContextProvider => { return async (context, request, response) => { const [{ savedObjects }] = await core.getStartServices(); + const user = await caseService.getUser({ request }); return { getCaseClient: () => { return new CaseClientImpl({ @@ -175,7 +177,7 @@ export class CasePlugin { connectorMappingsService, userActionService, alertsService, - request, + 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__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index dbcf5226316a6..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,7 +5,6 @@ * 2.0. */ -import { KibanaRequest } from 'src/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; import { createExternalCaseClient } from '../../../client'; import { @@ -25,10 +24,9 @@ export const createRouteContext = async (client: any, badAuth = false) => { const log = loggingSystemMock.create().get('case'); const esClient = elasticsearchServiceMock.createElasticsearchClient(); - const caseService = new CaseService( - log, - badAuth ? authenticationMock.createInvalid() : authenticationMock.create() - ); + const authc = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); + + const caseService = new CaseService(log, authc); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); const caseUserActionsServicePlugin = new CaseUserActionService(log); @@ -47,18 +45,12 @@ export const createRouteContext = async (client: any, badAuth = false) => { case: { getCaseClient: () => caseClient, }, - // TODO: remove - /* securitySolution: { - getAppClient: () => ({ - getSignalsIndex: () => '.siem-signals', - }), - },*/ } as unknown) as CasesRequestHandlerContext; const connectorMappingsService = await connectorMappingsServicePlugin.setup(); const caseClient = createExternalCaseClient({ savedObjectsClient: client, - request: {} as KibanaRequest, + user: authc.getCurrentUser(), caseService, caseConfigureService, connectorMappingsService, 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 fa6548ab4b343..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 @@ -32,7 +32,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic 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 id = request.query?.subCaseID ?? request.params.case_id; 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 f2937eb485a71..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 @@ -34,7 +34,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: 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({ 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 f7e2f580b5928..5a13b766cf4b7 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 @@ -18,7 +18,7 @@ import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/ 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, decodeComment } from '../../utils'; +import { escapeHatch, wrapError, decodeCommentPatch } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; import { CaseServiceSetup } from '../../../../services'; @@ -81,7 +81,7 @@ export function initPatchCommentApi({ ); const { id: queryCommentId, version: queryCommentVersion, ...queryRestAttributes } = query; - decodeComment(queryRestAttributes); + decodeCommentPatch(queryRestAttributes); const commentableCase = await getCommentableCase({ service: caseService, @@ -119,7 +119,7 @@ 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, 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 df43e56bd82f3..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(); 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 b46e03ed5332e..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,7 +58,7 @@ 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[] = []; 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 98e399fa2ab4b..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 @@ -137,7 +137,7 @@ 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({ 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 ad43d6d33c072..a1a7f4f9da8f5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -23,8 +23,6 @@ import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_ import { sortToSnake } from '../utils'; import { combineFilters } from '../../../common'; -// TODO: write unit tests for these functions - export const addStatusFilter = ({ status, appendFilter, 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 index c8df012acc66a..db701dd0fc82b 100644 --- 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 @@ -59,7 +59,7 @@ export function initDeleteSubCasesApi({ caseService, router, userActionService } 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, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); await userActionService.postUserActions({ 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 index ed57a3d6dc389..10211c1cb2471 100644 --- 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 @@ -9,10 +9,15 @@ 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 } from 'kibana/server'; +import { + SavedObjectsClientContract, + KibanaRequest, + SavedObject, + SavedObjectsFindResponse, +} from 'kibana/server'; import { CaseClient } from '../../../../client'; -import { CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types'; +import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../../../services'; import { CaseStatuses, @@ -28,12 +33,20 @@ import { SubCaseResponse, SubCasesResponseRt, User, + CommentAttributes, } from '../../../../../common/api'; import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; import { RouteDeps } from '../../types'; -import { escapeHatch, flattenSubCaseSavedObject, isAlertCommentSO, wrapError } from '../../utils'; +import { + AlertInfo, + escapeHatch, + flattenSubCaseSavedObject, + isGenOrAlertCommentAttributes, + wrapError, +} from '../../utils'; import { getCaseToUpdate } from '../helpers'; import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; +import { addAlertInfoToStatusMap } from '../../../../common'; interface UpdateArgs { client: SavedObjectsClientContract; @@ -166,6 +179,79 @@ function getValidUpdateRequests( }); } +/** + * 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, + subCasesMap, +}: { + subCasesToSync: SubCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseClient: CaseClient; + subCasesMap: 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 (isGenOrAlertCommentAttributes(alertComment.attributes)) { + const id = getID(alertComment); + const status = + id !== undefined + ? subCasesMap.get(id)?.attributes.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, @@ -204,7 +290,6 @@ async function update({ subCasesMap, }); - // TODO: extract to new function // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); const updatedAt = new Date().toISOString(); @@ -259,41 +344,13 @@ async function update({ ); }); - // TODO: extra to new function - for (const subCaseToSync of subCasesToSyncAlertsFor) { - const currentSubCase = subCasesMap.get(subCaseToSync.id); - const alertComments = await caseService.getAllSubCaseComments({ - client, - id: subCaseToSync.id, - options: { - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, - }, - }); - - // TODO: comment about why we need this (aka alerts might come from different indices? so dedup them) - const idsAndIndices = alertComments.saved_objects.reduce( - (acc: { ids: string[]; indices: Set }, comment) => { - if (isAlertCommentSO(comment)) { - const alertId = comment.attributes.alertId; - const ids = Array.isArray(alertId) ? alertId : [alertId]; - acc.ids.push(...ids); - acc.indices.add(comment.attributes.index); - } - return acc; - }, - { ids: [], indices: new Set() } - ); - - if (idsAndIndices.ids.length > 0) { - caseClient.updateAlertsStatus({ - ids: idsAndIndices.ids, - // We shouldn't really get in a case where the sub cases' status is undefined, but there wouldn't be anything to - // update in that case - status: subCaseToSync.status ?? currentSubCase?.attributes.status ?? CaseStatuses.open, - indices: idsAndIndices.indices, - }); - } - } + await updateAlerts({ + caseService, + client, + caseClient, + subCasesToSync: subCasesToSyncAlertsFor, + subCasesMap, + }); const returnUpdatedSubCases = updatedCases.saved_objects.reduce( (acc, updatedSO) => { diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 6c7eb0318c5de..7a0e270821378 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -27,7 +27,6 @@ import { ESCaseAttributes, CommentRequest, ContextTypeUserRt, - ContextTypeAlertRt, CommentRequestUserType, CommentRequestAlertType, CommentType, @@ -39,17 +38,20 @@ import { SubCaseAttributes, SubCaseResponse, CommentRequestGeneratedAlertType, - ContextTypeGeneratedAlertRt, + GeneratedAlertCommentRequestRt, SubCasesFindResponse, AttributesTypeAlerts, User, + GeneratedAlertRequestTypeField, + AlertCommentRequestRt, + CommentPatchRequestTypes, + AlertCommentAttributesRt, + AttributesTypeAlertsWithoutBasic, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; import { SortFieldCase } from './types'; -// TODO: refactor these functions to a common location, this is used by the caseClient too - export const transformNewSubCase = ({ createdAt, createdBy, @@ -104,13 +106,73 @@ type NewCommentArgs = CommentRequest & { username?: string | null; }; +/** + * Return the alert IDs from the comment if it is an alert style comment. Otherwise return an empty array. + */ +export const getAlertIdsFromAttributes = (comment: CommentAttributes): string[] => { + if (isGenOrAlertCommentAttributes(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 (isGenOrAlertCommentAttributes(comment)) { + acc.ids.push(...getAlertIdsFromAttributes(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() } + ); +}; + /** * Return the IDs from the comment. * - * @param comment the comment from the add comment request + * @param comment the comment from the add comment request or stored within a case */ -export const getAlertIds = (comment: CommentRequest): string[] => { - if (isGeneratedAlertContext(comment)) { +export const getAlertIdsFromRequest = (comment: CommentRequest): string[] => { + if (isCommentRequestTypeGenAlert(comment)) { const ids: string[] = []; if (Array.isArray(comment.alerts)) { ids.push( @@ -122,7 +184,7 @@ export const getAlertIds = (comment: CommentRequest): string[] => { ids.push(comment.alerts._id); } return ids; - } else if (isAlertContext(comment)) { + } else if (isCommentRequestTypeAlert(comment)) { return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; } else { return []; @@ -138,14 +200,14 @@ export const transformNewComment = ({ username, ...comment }: NewCommentArgs): CommentAttributes => { - if (isGeneratedAlertContext(comment)) { - const ids = getAlertIds(comment); + if (isCommentRequestTypeGenAlert(comment)) { + const ids = getAlertIdsFromRequest(comment); return { associationType, alertId: ids, index: comment.index, - type: comment.type, + type: CommentType.generatedAlert, created_at: createdDate, created_by: { email, full_name, username }, pushed_at: null, @@ -316,8 +378,8 @@ export const escapeHatch = schema.object({}, { unknowns: 'allow' }); /** * A type narrowing function for user comments. Exporting so integration tests can use it. */ -export const isUserContext = ( - context: CommentRequest | CommentAttributes +export const isCommentRequestTypeUser = ( + context: CommentRequest | CommentPatchRequestTypes ): context is CommentRequestUserType => { return context.type === CommentType.user; }; @@ -325,12 +387,18 @@ export const isUserContext = ( /** * A type narrowing function for alert comments. Exporting so integration tests can use it. */ -export const isAlertContext = ( - context: CommentRequest | CommentAttributes +export const isCommentRequestTypeAlert = ( + context: CommentRequest | CommentPatchRequestTypes ): context is CommentRequestAlertType => { return context.type === CommentType.alert; }; +const isPatchRequestTypeAlertOrGenAlert = ( + context: CommentPatchRequestTypes +): context is AttributesTypeAlertsWithoutBasic => { + 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 @@ -338,30 +406,42 @@ export const isAlertContext = ( * 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 isGeneratedAlertContext = ( +export const isCommentRequestTypeGenAlert = ( context: CommentRequest ): context is CommentRequestGeneratedAlertType => { - return context.type === CommentType.generatedAlert; + return context.type === GeneratedAlertRequestTypeField; }; -export const isAlertCommentSO = ( - comment: SavedObject -): comment is SavedObject => { - return ( - comment.attributes.type === CommentType.generatedAlert || - comment.attributes.type === CommentType.alert - ); +/** + * Returns true if the comment attribute is of type generated alert or alert. + */ +export const isGenOrAlertCommentAttributes = ( + comment: CommentAttributes +): comment is AttributesTypeAlerts => { + return comment.type === CommentType.generatedAlert || comment.type === CommentType.alert; }; -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 (isGeneratedAlertContext(comment)) { + } else if (isCommentRequestTypeAlert(comment)) { + pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isCommentRequestTypeGenAlert(comment)) { pipe( - excess(ContextTypeGeneratedAlertRt).decode(comment), + excess(GeneratedAlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity) ); } }; + +/** + * This is used to decode a patch request. The patch comment is different from a comment request because it only allows + * a user, alert, or generated alert to be patched. It does not allow a generated alert using the {_id: string} format. + */ +export const decodeCommentPatch = (comment: CommentPatchRequestTypes) => { + if (isCommentRequestTypeUser(comment)) { + pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isPatchRequestTypeAlertOrGenAlert(comment)) { + pipe(excess(AlertCommentAttributesRt).decode(comment), fold(throwErrors(badRequest), identity)); + } +}; diff --git a/x-pack/plugins/case/server/scripts/sub_cases/index.ts b/x-pack/plugins/case/server/scripts/sub_cases/index.ts index 8e613a248a694..2ea9718d18487 100644 --- a/x-pack/plugins/case/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -7,21 +7,18 @@ /* eslint-disable no-console */ import yargs from 'yargs'; import { KbnClient, ToolingLog } from '@kbn/dev-utils'; -import { CaseResponse, CaseType, ConnectorTypes } from '../../../common/api'; +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(); -// TODO: find actual type -interface ActionResp { - id: string; -} - -interface ExecuteResp { - status: string; -} - function createClient(argv: any): KbnClient { return new KbnClient({ log: new ToolingLog({ @@ -71,7 +68,7 @@ async function handleGenGroupAlerts(argv: any) { const client = createClient(argv); try { - const createdAction = await client.request({ + const createdAction = await client.request({ path: '/api/actions/action', method: 'POST', body: { @@ -108,7 +105,9 @@ async function handleGenGroupAlerts(argv: any) { } console.log('Case id: ', caseID); - const executeResp = await client.request({ + const executeResp = await client.request< + ActionTypeExecutorResult + >({ path: `/api/actions/action/${createdAction.data.id}/_execute`, method: 'POST', body: { @@ -142,8 +141,9 @@ async function handleGenGroupAlerts(argv: any) { } async function main() { - // TODO: this isn't waiting for some reason - await yargs(process.argv.slice(2)) + // 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: { diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index b6f9f0ce019e2..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, @@ -35,6 +34,7 @@ import { caseTypeField, } from '../../common/api'; import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; +import { defaultPage, defaultPerPage } from '../routes/api'; import { flattenCaseSavedObject, flattenSubCaseSavedObject, @@ -163,7 +163,6 @@ interface PatchSubCases { interface GetUserArgs { request: KibanaRequest; - response?: KibanaResponseFactory; } interface SubCasesMapWithPageInfo { @@ -284,9 +283,10 @@ export class CaseService implements CaseServiceSetup { client, options: subCaseOptions, ids: cases.saved_objects - .filter((caseInfo) => caseInfo.type === CaseType.collection) + .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); @@ -445,6 +445,13 @@ export class CaseService implements CaseServiceSetup { 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; @@ -502,8 +509,18 @@ export class CaseService implements CaseServiceSetup { return subCase.references.length > 0 ? subCase.references[0].id : undefined; }; + const emptyResponse = { + subCasesMap: new Map(), + page: 0, + perPage: 0, + }; + if (!options) { - return { subCasesMap: new Map(), page: 0, perPage: 0 }; + return emptyResponse; + } + + if (ids.length <= 0) { + return emptyResponse; } const subCases = await this.findSubCases({ @@ -563,6 +580,10 @@ export class CaseService implements CaseServiceSetup { options, ids, }: FindSubCasesStatusStats): Promise { + if (ids.length <= 0) { + return 0; + } + const subCases = await this.findSubCases({ client, options: { @@ -781,6 +802,15 @@ export class CaseService implements CaseServiceSetup { 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({ @@ -864,6 +894,14 @@ export class CaseService implements CaseServiceSetup { }: FindCaseCommentsArgs): Promise> { try { const refs = this.asArray(id).map((caseID) => ({ type: CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { + return { + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, + }; + } let filter: string | undefined; if (!includeSubCaseComments) { @@ -901,6 +939,14 @@ export class CaseService implements CaseServiceSetup { }: 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, + }; + } this.log.debug(`Attempting to GET all comments for sub case caseID ${id}`); return this.getAllComments({ @@ -936,8 +982,8 @@ export class CaseService implements CaseServiceSetup { throw error; } } - // TODO: remove response - public async getUser({ request, response }: GetUserArgs) { + + public async getUser({ request }: GetUserArgs) { try { this.log.debug(`Attempting to authenticate a user`); if (this.authentication != null) { 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 9997ee0f7b2b9..3a524fd365c30 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 @@ -847,11 +847,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\n - [subActionParams.comment.1.type]: expected value to equal [alert]\n - [subActionParams.comment.2.type]: expected value to equal [generated_alert]`, + 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_request]`, retry: false, }); } 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 a4320c28526b3..a6b5d98f942d8 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -23,11 +23,12 @@ import { AssociationType, CollectionWithSubCaseResponse, SubCasesFindResponse, + GeneratedAlertRequestTypeField, } from '../../../../plugins/case/common/api'; import { - getAlertIds, - isGeneratedAlertContext, - isAlertContext, + getAlertIdsFromRequest, + isCommentRequestTypeGenAlert, + isCommentRequestTypeAlert, } from '../../../../plugins/case/server/routes/api/utils'; export const defaultUser = { email: null, full_name: null, username: 'elastic' }; export const postCaseReq: CasePostRequest = { @@ -75,7 +76,7 @@ export const postCommentAlertReq: CommentRequestAlertType = { export const postCommentGenAlertReq: CommentRequestGeneratedAlertType = { alerts: [{ _id: 'test-id' }, { _id: 'test-id2' }], index: 'test-index', - type: CommentType.generatedAlert, + type: GeneratedAlertRequestTypeField, }; export const postCaseResp = ( @@ -115,15 +116,15 @@ export const commentsResp = ({ pushed_by: null, updated_by: null, }; - if (isGeneratedAlertContext(comment)) { + if (isCommentRequestTypeGenAlert(comment)) { return { associationType, - alertId: getAlertIds(comment), + alertId: getAlertIdsFromRequest(comment), index: comment.index, - type: comment.type, + type: CommentType.generatedAlert, ...baseFields, }; - } else if (isAlertContext(comment)) { + } else if (isCommentRequestTypeAlert(comment)) { return { associationType, ...comment,