diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index 2b4e576dcf7f1..02a20b014aa8a 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -127,6 +127,7 @@ export const MAX_DELETE_IDS_LENGTH = 100 as const; export const MAX_SUGGESTED_PROFILES = 10 as const; export const MAX_CASES_TO_UPDATE = 100 as const; export const MAX_BULK_CREATE_ATTACHMENTS = 100 as const; +export const MAX_USER_ACTIONS_PER_CASE = 10000 as const; export const MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES = 100 as const; /** diff --git a/x-pack/plugins/cases/common/utils/index.ts b/x-pack/plugins/cases/common/utils/index.ts index b8be12831200e..ef509fb8bfd86 100644 --- a/x-pack/plugins/cases/common/utils/index.ts +++ b/x-pack/plugins/cases/common/utils/index.ts @@ -7,3 +7,4 @@ export * from './connectors_api'; export * from './capabilities'; +export * from './validators'; diff --git a/x-pack/plugins/cases/common/utils/validators.test.ts b/x-pack/plugins/cases/common/utils/validators.test.ts index 9b0a20320118d..1daf64f90c611 100644 --- a/x-pack/plugins/cases/common/utils/validators.test.ts +++ b/x-pack/plugins/cases/common/utils/validators.test.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { MAX_ASSIGNEES_PER_CASE } from '../constants'; -import { areTotalAssigneesInvalid } from './validators'; +import { createUserActionServiceMock } from '../../server/services/mocks'; +import { MAX_ASSIGNEES_PER_CASE, MAX_USER_ACTIONS_PER_CASE } from '../constants'; +import { areTotalAssigneesInvalid, validateMaxUserActions } from './validators'; describe('validators', () => { describe('areTotalAssigneesInvalid', () => { @@ -31,4 +32,37 @@ describe('validators', () => { expect(areTotalAssigneesInvalid(generateAssignees(MAX_ASSIGNEES_PER_CASE + 1))).toBe(true); }); }); + + describe('validateMaxUserActions', () => { + const caseId = 'test-case'; + const userActionService = createUserActionServiceMock(); + + userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ + [caseId]: MAX_USER_ACTIONS_PER_CASE - 1, + }); + + it('does not throw if the limit is not reached', async () => { + await expect( + validateMaxUserActions({ caseId, userActionService, userActionsToAdd: 1 }) + ).resolves.not.toThrow(); + }); + + it('throws if the max user actions per case limit is reached', async () => { + await expect( + validateMaxUserActions({ caseId, userActionService, userActionsToAdd: 2 }) + ).rejects.toThrow( + `The case with id ${caseId} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` + ); + }); + + it('the caseId does not exist in the response', async () => { + userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ + foobar: MAX_USER_ACTIONS_PER_CASE - 1, + }); + + await expect( + validateMaxUserActions({ caseId, userActionService, userActionsToAdd: 1 }) + ).resolves.not.toThrow(); + }); + }); }); diff --git a/x-pack/plugins/cases/common/utils/validators.ts b/x-pack/plugins/cases/common/utils/validators.ts index 9311c3a9a8bae..cd85401d7a6bd 100644 --- a/x-pack/plugins/cases/common/utils/validators.ts +++ b/x-pack/plugins/cases/common/utils/validators.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { MAX_ASSIGNEES_PER_CASE } from '../constants'; +import Boom from '@hapi/boom'; + +import type { CaseUserActionService } from '../../server/services'; +import { MAX_ASSIGNEES_PER_CASE, MAX_USER_ACTIONS_PER_CASE } from '../constants'; import type { CaseAssignees } from '../types/domain'; export const areTotalAssigneesInvalid = (assignees?: CaseAssignees): boolean => { @@ -15,3 +18,25 @@ export const areTotalAssigneesInvalid = (assignees?: CaseAssignees): boolean => return assignees.length > MAX_ASSIGNEES_PER_CASE; }; + +export const validateMaxUserActions = async ({ + caseId, + userActionService, + userActionsToAdd, +}: { + caseId: string; + userActionService: CaseUserActionService; + userActionsToAdd: number; +}) => { + const result = await userActionService.getMultipleCasesUserActionsTotal({ + caseIds: [caseId], + }); + + const totalUserActions = result[caseId] ?? 0; + + if (totalUserActions + userActionsToAdd > MAX_USER_ACTIONS_PER_CASE) { + throw Boom.badRequest( + `The case with id ${caseId} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` + ); + } +}; diff --git a/x-pack/plugins/cases/server/client/attachments/add.test.ts b/x-pack/plugins/cases/server/client/attachments/add.test.ts index b78ec1219088b..ff610cba6cd2b 100644 --- a/x-pack/plugins/cases/server/client/attachments/add.test.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.test.ts @@ -5,13 +5,19 @@ * 2.0. */ +import { MAX_COMMENT_LENGTH, MAX_USER_ACTIONS_PER_CASE } from '../../../common/constants'; import { comment } from '../../mocks'; +import { createUserActionServiceMock } from '../../services/mocks'; import { createCasesClientMockArgs } from '../mocks'; -import { MAX_COMMENT_LENGTH } from '../../../common/constants'; import { addComment } from './add'; describe('addComment', () => { + const caseId = 'test-case'; + const clientArgs = createCasesClientMockArgs(); + const userActionService = createUserActionServiceMock(); + + clientArgs.services.userActionService = userActionService; beforeEach(() => { jest.clearAllMocks(); @@ -20,7 +26,7 @@ describe('addComment', () => { it('throws with excess fields', async () => { await expect( // @ts-expect-error: excess attribute - addComment({ comment: { ...comment, foo: 'bar' }, caseId: 'test-case' }, clientArgs) + addComment({ comment: { ...comment, foo: 'bar' }, caseId }, clientArgs) ).rejects.toThrow('invalid keys "foo"'); }); @@ -28,7 +34,7 @@ describe('addComment', () => { const longComment = 'x'.repeat(MAX_COMMENT_LENGTH + 1); await expect( - addComment({ comment: { ...comment, comment: longComment }, caseId: 'test-case' }, clientArgs) + addComment({ comment: { ...comment, comment: longComment }, caseId }, clientArgs) ).rejects.toThrow( `Failed while adding a comment to case id: test-case error: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.` ); @@ -36,7 +42,7 @@ describe('addComment', () => { it('should throw an error if the comment is an empty string', async () => { await expect( - addComment({ comment: { ...comment, comment: '' }, caseId: 'test-case' }, clientArgs) + addComment({ comment: { ...comment, comment: '' }, caseId }, clientArgs) ).rejects.toThrow( 'Failed while adding a comment to case id: test-case error: Error: The comment field cannot be an empty string.' ); @@ -44,9 +50,19 @@ describe('addComment', () => { it('should throw an error if the description is a string with empty characters', async () => { await expect( - addComment({ comment: { ...comment, comment: ' ' }, caseId: 'test-case' }, clientArgs) + addComment({ comment: { ...comment, comment: ' ' }, caseId }, clientArgs) ).rejects.toThrow( 'Failed while adding a comment to case id: test-case error: Error: The comment field cannot be an empty string.' ); }); + + it(`throws error when the case user actions become > ${MAX_USER_ACTIONS_PER_CASE}`, async () => { + userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ + [caseId]: MAX_USER_ACTIONS_PER_CASE, + }); + + await expect(addComment({ comment, caseId }, clientArgs)).rejects.toThrow( + `The case with id ${caseId} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` + ); + }); }); diff --git a/x-pack/plugins/cases/server/client/attachments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts index 987b87bdcf52c..5eba3d4b81f02 100644 --- a/x-pack/plugins/cases/server/client/attachments/add.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.ts @@ -7,6 +7,7 @@ import { SavedObjectsUtils } from '@kbn/core/server'; +import { validateMaxUserActions } from '../../../common/utils'; import { AttachmentRequestRt } from '../../../common/types/api'; import type { Case } from '../../../common/types/domain'; import { decodeWithExcessOrThrow } from '../../../common/api'; @@ -31,11 +32,13 @@ export const addComment = async (addArgs: AddArgs, clientArgs: CasesClientArgs): authorization, persistableStateAttachmentTypeRegistry, externalReferenceAttachmentTypeRegistry, + services: { userActionService }, } = clientArgs; try { const query = decodeWithExcessOrThrow(AttachmentRequestRt)(comment); + await validateMaxUserActions({ caseId, userActionService, userActionsToAdd: 1 }); decodeCommentRequest(comment, externalReferenceAttachmentTypeRegistry); const savedObjectID = SavedObjectsUtils.generateId(); diff --git a/x-pack/plugins/cases/server/client/attachments/bulk_create.test.ts b/x-pack/plugins/cases/server/client/attachments/bulk_create.test.ts index 5d72eb17277b0..bd65dcd3896f1 100644 --- a/x-pack/plugins/cases/server/client/attachments/bulk_create.test.ts +++ b/x-pack/plugins/cases/server/client/attachments/bulk_create.test.ts @@ -7,11 +7,21 @@ import { comment, actionComment } from '../../mocks'; import { createCasesClientMockArgs } from '../mocks'; -import { MAX_COMMENT_LENGTH, MAX_BULK_CREATE_ATTACHMENTS } from '../../../common/constants'; +import { + MAX_COMMENT_LENGTH, + MAX_BULK_CREATE_ATTACHMENTS, + MAX_USER_ACTIONS_PER_CASE, +} from '../../../common/constants'; import { bulkCreate } from './bulk_create'; +import { createUserActionServiceMock } from '../../services/mocks'; describe('bulkCreate', () => { + const caseId = 'test-case'; + const clientArgs = createCasesClientMockArgs(); + const userActionService = createUserActionServiceMock(); + + clientArgs.services.userActionService = userActionService; beforeEach(() => { jest.clearAllMocks(); @@ -20,18 +30,30 @@ describe('bulkCreate', () => { it('throws with excess fields', async () => { await expect( // @ts-expect-error: excess attribute - bulkCreate({ attachments: [{ ...comment, foo: 'bar' }], caseId: 'test-case' }, clientArgs) + bulkCreate({ attachments: [{ ...comment, foo: 'bar' }], caseId }, clientArgs) ).rejects.toThrow('invalid keys "foo"'); }); it(`throws error when attachments are more than ${MAX_BULK_CREATE_ATTACHMENTS}`, async () => { const attachments = Array(MAX_BULK_CREATE_ATTACHMENTS + 1).fill(comment); - await expect(bulkCreate({ attachments, caseId: 'test-case' }, clientArgs)).rejects.toThrow( + await expect(bulkCreate({ attachments, caseId }, clientArgs)).rejects.toThrow( `The length of the field attachments is too long. Array must be of length <= ${MAX_BULK_CREATE_ATTACHMENTS}.` ); }); + it(`throws error when the case user actions become > ${MAX_USER_ACTIONS_PER_CASE}`, async () => { + userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ + [caseId]: MAX_USER_ACTIONS_PER_CASE - 1, + }); + + await expect( + bulkCreate({ attachments: [comment, comment], caseId }, clientArgs) + ).rejects.toThrow( + `The case with id ${caseId} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` + ); + }); + describe('comments', () => { it('should throw an error if the comment length is too long', async () => { const longComment = Array(MAX_COMMENT_LENGTH + 1) @@ -39,10 +61,7 @@ describe('bulkCreate', () => { .toString(); await expect( - bulkCreate( - { attachments: [{ ...comment, comment: longComment }], caseId: 'test-case' }, - clientArgs - ) + bulkCreate({ attachments: [{ ...comment, comment: longComment }], caseId }, clientArgs) ).rejects.toThrow( `Failed while bulk creating attachment to case id: test-case error: Error: The length of the comment is too long. The maximum length is ${MAX_COMMENT_LENGTH}.` ); @@ -50,7 +69,7 @@ describe('bulkCreate', () => { it('should throw an error if the comment is an empty string', async () => { await expect( - bulkCreate({ attachments: [{ ...comment, comment: '' }], caseId: 'test-case' }, clientArgs) + bulkCreate({ attachments: [{ ...comment, comment: '' }], caseId }, clientArgs) ).rejects.toThrow( 'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.' ); @@ -58,10 +77,7 @@ describe('bulkCreate', () => { it('should throw an error if the description is a string with empty characters', async () => { await expect( - bulkCreate( - { attachments: [{ ...comment, comment: ' ' }], caseId: 'test-case' }, - clientArgs - ) + bulkCreate({ attachments: [{ ...comment, comment: ' ' }], caseId }, clientArgs) ).rejects.toThrow( 'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.' ); @@ -76,7 +92,7 @@ describe('bulkCreate', () => { await expect( bulkCreate( - { attachments: [{ ...actionComment, comment: longComment }], caseId: 'test-case' }, + { attachments: [{ ...actionComment, comment: longComment }], caseId }, clientArgs ) ).rejects.toThrow( @@ -86,10 +102,7 @@ describe('bulkCreate', () => { it('should throw an error if the comment is an empty string', async () => { await expect( - bulkCreate( - { attachments: [{ ...actionComment, comment: '' }], caseId: 'test-case' }, - clientArgs - ) + bulkCreate({ attachments: [{ ...actionComment, comment: '' }], caseId }, clientArgs) ).rejects.toThrow( 'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.' ); @@ -97,10 +110,7 @@ describe('bulkCreate', () => { it('should throw an error if the description is a string with empty characters', async () => { await expect( - bulkCreate( - { attachments: [{ ...actionComment, comment: ' ' }], caseId: 'test-case' }, - clientArgs - ) + bulkCreate({ attachments: [{ ...actionComment, comment: ' ' }], caseId }, clientArgs) ).rejects.toThrow( 'Failed while bulk creating attachment to case id: test-case error: Error: The comment field cannot be an empty string.' ); diff --git a/x-pack/plugins/cases/server/client/attachments/bulk_create.ts b/x-pack/plugins/cases/server/client/attachments/bulk_create.ts index 693336ffd31f7..8e265ab9c88d5 100644 --- a/x-pack/plugins/cases/server/client/attachments/bulk_create.ts +++ b/x-pack/plugins/cases/server/client/attachments/bulk_create.ts @@ -7,6 +7,7 @@ import { SavedObjectsUtils } from '@kbn/core/server'; +import { validateMaxUserActions } from '../../../common/utils'; import type { AttachmentRequest } from '../../../common/types/api'; import { BulkCreateAttachmentsRequestRt } from '../../../common/types/api'; import type { Case } from '../../../common/types/domain'; @@ -33,10 +34,16 @@ export const bulkCreate = async ( authorization, externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, + services: { userActionService }, } = clientArgs; try { decodeWithExcessOrThrow(BulkCreateAttachmentsRequestRt)(attachments); + await validateMaxUserActions({ + caseId, + userActionService, + userActionsToAdd: attachments.length, + }); attachments.forEach((attachment) => { decodeCommentRequest(attachment, externalReferenceAttachmentTypeRegistry); diff --git a/x-pack/plugins/cases/server/client/cases/update.test.ts b/x-pack/plugins/cases/server/client/cases/update.test.ts index 5a6d81f020928..039f4e8582804 100644 --- a/x-pack/plugins/cases/server/client/cases/update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/update.test.ts @@ -12,6 +12,7 @@ import { MAX_LENGTH_PER_TAG, MAX_TITLE_LENGTH, MAX_CASES_TO_UPDATE, + MAX_USER_ACTIONS_PER_CASE, } from '../../../common/constants'; import { mockCases } from '../../mocks'; import { createCasesClientMockArgs } from '../mocks'; @@ -737,5 +738,116 @@ describe('update', () => { 'Error: The length of the field cases is too short. Array must be of length >= 1.' ); }); + + describe('Validate max user actions per page', () => { + const casesClient = createCasesClientMockArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + casesClient.services.caseService.getCases.mockResolvedValue({ + saved_objects: [{ ...mockCases[0] }, { ...mockCases[1] }], + }); + casesClient.services.caseService.getAllCaseComments.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 10, + page: 1, + }); + }); + + it('passes validation if max user actions per case is not reached', async () => { + casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ + [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE - 1, + }); + + // @ts-ignore: only the array length matters here + casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({ + [mockCases[0].id]: [1], + }); + + casesClient.services.caseService.patchCases.mockResolvedValue({ + saved_objects: [{ ...mockCases[0] }], + }); + + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + title: 'This is a test case!!', + }, + ], + }, + casesClient + ) + ).resolves.not.toThrow(); + }); + + it(`throws an error when the user actions to be created will reach ${MAX_USER_ACTIONS_PER_CASE}`, async () => { + casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ + [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE, + }); + + // @ts-ignore: only the array length matters here + casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({ + [mockCases[0].id]: [1, 2, 3], + }); + + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + title: 'This is a test case!!', + }, + ], + }, + casesClient + ) + ).rejects.toThrow( + `Error: The case with case id ${mockCases[0].id} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` + ); + }); + + it('throws an error when trying to update multiple cases and one of them is expected to fail', async () => { + casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ + [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE, + [mockCases[1].id]: 0, + }); + + // @ts-ignore: only the array length matters here + casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({ + [mockCases[0].id]: [1, 2, 3], + [mockCases[1].id]: [1], + }); + + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + title: 'This is supposed to fail', + }, + + { + id: mockCases[1].id, + version: mockCases[1].version ?? '', + title: 'This is supposed to pass', + }, + ], + }, + casesClient + ) + ).rejects.toThrow( + `Error: The case with case id ${mockCases[0].id} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` + ); + }); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index c7b2e124dbf76..1844b12b3ab07 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -17,28 +17,29 @@ import type { import { nodeBuilder } from '@kbn/es-query'; +import type { AlertService, CasesService, CaseUserActionService } from '../../services'; +import type { UpdateAlertStatusRequest } from '../alerts/types'; +import type { CasesClientArgs } from '..'; +import type { OwnerEntity } from '../../authorization'; +import type { PatchCasesArgs } from '../../services/cases/types'; +import type { UserActionEvent, UserActionsDict } from '../../services/user_actions/types'; + import type { CasePatchRequest, CasesPatchRequest } from '../../../common/types/api'; import { areTotalAssigneesInvalid } from '../../../common/utils/validators'; -import { decodeWithExcessOrThrow } from '../../../common/api'; import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, MAX_ASSIGNEES_PER_CASE, + MAX_USER_ACTIONS_PER_CASE, } from '../../../common/constants'; - -import { arraysDifference, getCaseToUpdate } from '../utils'; - -import type { AlertService, CasesService } from '../../services'; +import { Operations } from '../../authorization'; import { createCaseError } from '../../common/error'; import { createAlertUpdateStatusRequest, flattenCaseSavedObject, isCommentRequestTypeAlert, } from '../../common/utils'; -import type { UpdateAlertStatusRequest } from '../alerts/types'; -import type { CasesClientArgs } from '..'; -import type { OwnerEntity } from '../../authorization'; -import { Operations } from '../../authorization'; +import { arraysDifference, getCaseToUpdate } from '../utils'; import { dedupAssignees, getClosedInfoForUpdate, getDurationForUpdate } from './utils'; import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; import type { LicensingService } from '../../services/licensing'; @@ -53,6 +54,7 @@ import type { AttachmentAttributes, } from '../../../common/types/domain'; import { CasesPatchRequestRt } from '../../../common/types/api'; +import { decodeWithExcessOrThrow } from '../../../common/api'; import { CasesRt, CaseStatuses, AttachmentType } from '../../../common/types/domain'; /** @@ -67,6 +69,36 @@ function throwIfUpdateOwner(requests: UpdateRequestWithOriginalCase[]) { } } +/** + * Throws an error if any of the requests attempt to create a number of user actions that would put + * it's case over the limit. + */ +async function throwIfMaxUserActionsReached({ + userActionsDict, + userActionService, +}: { + userActionsDict: UserActionsDict; + userActionService: CaseUserActionService; +}) { + if (userActionsDict == null) { + return; + } + + const currentTotals = await userActionService.getMultipleCasesUserActionsTotal({ + caseIds: Object.keys(userActionsDict), + }); + + Object.keys(currentTotals).forEach((caseId) => { + const totalToAdd = userActionsDict?.[caseId]?.length ?? 0; + + if (currentTotals[caseId] + totalToAdd > MAX_USER_ACTIONS_PER_CASE) { + throw Boom.badRequest( + `The case with case id ${caseId} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` + ); + } + }); +} + /** * Throws an error if any of the requests attempt to update the assignees of the case * without the appropriate license @@ -364,9 +396,16 @@ export const update = async ( throwIfUpdateAssigneesWithoutValidLicense(casesToUpdate, hasPlatinumLicense); throwIfTotalAssigneesAreInvalid(casesToUpdate); + const patchCasesPayload = createPatchCasesPayload({ user, casesToUpdate }); + const userActionsDict = userActionService.creator.buildUserActions({ + updatedCases: patchCasesPayload, + user, + }); + + await throwIfMaxUserActionsReached({ userActionsDict, userActionService }); notifyPlatinumUsage(licensingService, casesToUpdate); - const updatedCases = await patchCases({ caseService, user, casesToUpdate }); + const updatedCases = await patchCases({ caseService, patchCasesPayload }); // 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. @@ -413,10 +452,15 @@ export const update = async ( return flattenCases; }, [] as Case[]); + const builtUserActions = + userActionsDict != null + ? Object.keys(userActionsDict).reduce((acc, key) => { + return [...acc, ...userActionsDict[key]]; + }, []) + : []; + await userActionService.creator.bulkCreateUpdateCase({ - originalCases: myCases.saved_objects, - updatedCases: updatedCases.saved_objects, - user, + builtUserActions, }); const casesAndAssigneesToNotifyForAssignment = getCasesAndAssigneesToNotifyForAssignment( @@ -442,18 +486,16 @@ export const update = async ( } }; -const patchCases = async ({ - caseService, +const createPatchCasesPayload = ({ casesToUpdate, user, }: { - caseService: CasesService; casesToUpdate: UpdateRequestWithOriginalCase[]; user: User; -}) => { +}): PatchCasesArgs => { const updatedDt = new Date().toISOString(); - const updatedCases = await caseService.patchCases({ + return { cases: casesToUpdate.map(({ updateReq, originalCase }) => { // intentionally removing owner from the case so that we don't accidentally allow it to be updated const { id: caseId, version, owner, assignees, ...updateCaseAttributes } = updateReq; @@ -483,9 +525,17 @@ const patchCases = async ({ }; }), refresh: false, - }); + }; +}; - return updatedCases; +const patchCases = async ({ + caseService, + patchCasesPayload, +}: { + caseService: CasesService; + patchCasesPayload: PatchCasesArgs; +}) => { + return caseService.patchCases(patchCasesPayload); }; const getCasesAndAssigneesToNotifyForAssignment = ( diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index b43a3c226b1e4..7a9507be8b080 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -97,6 +97,7 @@ const createUserActionPersisterServiceMock = (): CaseUserActionPersisterServiceM const service: PublicMethodsOf = { bulkAuditLogCaseDeletion: jest.fn(), bulkCreateUpdateCase: jest.fn(), + buildUserActions: jest.fn(), bulkCreateAttachmentDeletion: jest.fn(), bulkCreateAttachmentCreation: jest.fn(), createUserAction: jest.fn(), @@ -126,6 +127,7 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => { getAll: jest.fn(), getUniqueConnectors: jest.fn(), getUserActionIdsForCases: jest.fn(), + getMultipleCasesUserActionsTotal: jest.fn(), getCaseUserActionStats: jest.fn(), getUsers: jest.fn(), }; diff --git a/x-pack/plugins/cases/server/services/user_actions/index.test.ts b/x-pack/plugins/cases/server/services/user_actions/index.test.ts index b59172b6999b8..b09b8aba8d114 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.test.ts @@ -12,27 +12,27 @@ import type { SavedObject, SavedObjectsBulkCreateObject, SavedObjectsFindResponse, - SavedObjectsUpdateResponse, } from '@kbn/core/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; -import { - CaseSeverity, - CaseStatuses, - UserActionActions, - UserActionTypes, -} from '../../../common/types/domain'; -import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; +import type { CaseUserActionWithoutReferenceIds } from '../../../common/types/domain'; +import type { UserActionEvent } from './types'; -import { createCaseSavedObjectResponse, createSOFindResponse } from '../test_utils'; +import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; +import { createSOFindResponse } from '../test_utils'; import { casePayload, externalService, - originalCases, - updatedCases, attachments, - updatedAssigneesCases, - originalCasesWithAssignee, - updatedTagsCases, + patchRemoveAssigneesCasesRequest, + patchCasesRequest, + patchAssigneesCasesRequest, + patchAddRemoveAssigneesCasesRequest, + patchTagsCasesRequest, + getBuiltUserActions, + getAssigneesAddedUserActions, + getAssigneesRemovedUserActions, + getAssigneesAddedRemovedUserActions, + getTagsAddedRemovedUserActions, } from './mocks'; import { CaseUserActionService } from '.'; import { createPersistableStateAttachmentTypeRegistryMock } from '../../attachment_framework/mocks'; @@ -44,9 +44,11 @@ import { pushConnectorUserAction, } from './test_utils'; import { comment } from '../../mocks'; -import type { - CaseUserActionWithoutReferenceIds, - CaseAttributes, +import { + UserActionActions, + UserActionTypes, + CaseSeverity, + CaseStatuses, } from '../../../common/types/domain'; describe('CaseUserActionService', () => { @@ -506,13 +508,73 @@ describe('CaseUserActionService', () => { }); }); + describe('buildUserActions', () => { + it('creates the correct user actions when bulk updating cases', async () => { + expect( + await service.creator.buildUserActions({ + updatedCases: patchCasesRequest, + user: commonArgs.user, + }) + ).toEqual(getBuiltUserActions({ isMock: false })); + }); + + it('creates the correct user actions when an assignee is added', async () => { + expect( + await service.creator.buildUserActions({ + updatedCases: patchAssigneesCasesRequest, + user: commonArgs.user, + }) + ).toEqual(getAssigneesAddedUserActions({ isMock: false })); + }); + + it('creates the correct user actions when an assignee is removed', async () => { + expect( + await service.creator.buildUserActions({ + updatedCases: patchRemoveAssigneesCasesRequest, + user: commonArgs.user, + }) + ).toEqual(getAssigneesRemovedUserActions({ isMock: false })); + }); + + it('creates the correct user actions when assignees are added and removed', async () => { + expect( + await service.creator.buildUserActions({ + updatedCases: patchAddRemoveAssigneesCasesRequest, + user: commonArgs.user, + }) + ).toEqual( + getAssigneesAddedRemovedUserActions({ + isMock: false, + }) + ); + }); + + it('creates the correct user actions when tags are added and removed', async () => { + expect( + await service.creator.buildUserActions({ + updatedCases: patchTagsCasesRequest, + user: commonArgs.user, + }) + ).toEqual( + getTagsAddedRemovedUserActions({ + isMock: false, + }) + ); + }); + }); + describe('bulkCreateUpdateCase', () => { + const mockBuiltUserActions = getBuiltUserActions({ isMock: true }); + const builtUserActions = Object.keys(mockBuiltUserActions).reduce( + (acc, key) => { + return [...acc, ...mockBuiltUserActions[key]]; + }, + [] + ); + it('creates the correct user actions when bulk updating cases', async () => { await service.creator.bulkCreateUpdateCase({ - ...commonArgs, - originalCases, - updatedCases, - user: commonArgs.user, + builtUserActions, }); expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith( @@ -582,6 +644,22 @@ describe('CaseUserActionService', () => { ], type: 'cases-user-actions', }, + { + attributes: { + action: UserActionActions.update, + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + type: 'category', + owner: 'securitySolution', + payload: { category: 'pizza toppings' }, + }, + references: [{ id: '1', name: 'associated-cases', type: 'cases' }], + type: 'cases-user-actions', + }, { attributes: { action: UserActionActions.update, @@ -677,13 +755,10 @@ describe('CaseUserActionService', () => { it('logs the correct user actions when bulk updating cases', async () => { await service.creator.bulkCreateUpdateCase({ - ...commonArgs, - originalCases, - updatedCases, - user: commonArgs.user, + builtUserActions, }); - expect(mockAuditLogger.log).toBeCalledTimes(8); + expect(mockAuditLogger.log).toBeCalledTimes(9); expect(mockAuditLogger.log.mock.calls).toMatchInlineSnapshot(` Array [ Array [ @@ -704,7 +779,7 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User updated the title for case id: 1 - user action id: 0", + "message": undefined, }, ], Array [ @@ -725,7 +800,7 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User updated the status for case id: 1 - user action id: 1", + "message": undefined, }, ], Array [ @@ -746,7 +821,28 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User changed the case connector to id: 456 for case id: 1 - user action id: 2", + "message": undefined, + }, + ], + Array [ + Object { + "event": Object { + "action": "case_user_action_update_case_category", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": undefined, }, ], Array [ @@ -767,7 +863,7 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User updated the description for case id: 2 - user action id: 3", + "message": undefined, }, ], Array [ @@ -788,7 +884,7 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User added tags to case id: 2 - user action id: 4", + "message": undefined, }, ], Array [ @@ -809,7 +905,7 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User deleted tags in case id: 2 - user action id: 5", + "message": undefined, }, ], Array [ @@ -830,7 +926,7 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User updated the settings for case id: 2 - user action id: 6", + "message": undefined, }, ], Array [ @@ -851,19 +947,23 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User updated the severity for case id: 2 - user action id: 7", + "message": undefined, }, ], ] `); }); + const mockAssigneesAddedUserActions = getAssigneesAddedUserActions({ isMock: true }); + const assigneesAddedUserActions = Object.keys(mockAssigneesAddedUserActions).reduce< + UserActionEvent[] + >((acc, key) => { + return [...acc, ...mockAssigneesAddedUserActions[key]]; + }, []); + it('creates the correct user actions when an assignee is added', async () => { await service.creator.bulkCreateUpdateCase({ - ...commonArgs, - originalCases, - updatedCases: updatedAssigneesCases, - user: commonArgs.user, + builtUserActions: assigneesAddedUserActions, }); expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(` @@ -907,10 +1007,7 @@ describe('CaseUserActionService', () => { it('logs the correct user actions when an assignee is added', async () => { await service.creator.bulkCreateUpdateCase({ - ...commonArgs, - originalCases, - updatedCases: updatedAssigneesCases, - user: commonArgs.user, + builtUserActions: assigneesAddedUserActions, }); expect(mockAuditLogger.log).toBeCalledTimes(1); @@ -934,29 +1031,23 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User assigned uids: [1] to case id: 1 - user action id: 0", + "message": undefined, }, ], ] `); }); - it('creates the correct user actions when an assignee is removed', async () => { - const casesWithAssigneeRemoved: Array> = [ - { - ...createCaseSavedObjectResponse(), - id: '1', - attributes: { - assignees: [], - }, - }, - ]; + const mockAssigneesRemovedUserActions = getAssigneesRemovedUserActions({ isMock: true }); + const assigneesRemovedUserActions = Object.keys(mockAssigneesRemovedUserActions).reduce< + UserActionEvent[] + >((acc, key) => { + return [...acc, ...mockAssigneesRemovedUserActions[key]]; + }, []); + it('creates the correct user actions when an assignee is removed', async () => { await service.creator.bulkCreateUpdateCase({ - ...commonArgs, - originalCases: originalCasesWithAssignee, - updatedCases: casesWithAssigneeRemoved, - user: commonArgs.user, + builtUserActions: assigneesRemovedUserActions, }); expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(` @@ -999,21 +1090,8 @@ describe('CaseUserActionService', () => { }); it('logs the correct user actions when an assignee is removed', async () => { - const casesWithAssigneeRemoved: Array> = [ - { - ...createCaseSavedObjectResponse(), - id: '1', - attributes: { - assignees: [], - }, - }, - ]; - await service.creator.bulkCreateUpdateCase({ - ...commonArgs, - originalCases: originalCasesWithAssignee, - updatedCases: casesWithAssigneeRemoved, - user: commonArgs.user, + builtUserActions: assigneesRemovedUserActions, }); expect(mockAuditLogger.log).toBeCalledTimes(1); @@ -1037,29 +1115,25 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User unassigned uids: [1] from case id: 1 - user action id: 0", + "message": undefined, }, ], ] `); }); - it('creates the correct user actions when assignees are added and removed', async () => { - const caseAssignees: Array> = [ - { - ...createCaseSavedObjectResponse(), - id: '1', - attributes: { - assignees: [{ uid: '2' }], - }, - }, - ]; + const mockAssigneesAddedRemovedUserActions = getAssigneesAddedRemovedUserActions({ + isMock: true, + }); + const assigneesAddedRemovedUserActions = Object.keys( + mockAssigneesAddedRemovedUserActions + ).reduce((acc, key) => { + return [...acc, ...mockAssigneesAddedRemovedUserActions[key]]; + }, []); + it('creates the correct user actions when assignees are added and removed', async () => { await service.creator.bulkCreateUpdateCase({ - ...commonArgs, - originalCases: originalCasesWithAssignee, - updatedCases: caseAssignees, - user: commonArgs.user, + builtUserActions: assigneesAddedRemovedUserActions, }); expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(` @@ -1130,21 +1204,8 @@ describe('CaseUserActionService', () => { }); it('logs the correct user actions when assignees are added and removed', async () => { - const caseAssignees: Array> = [ - { - ...createCaseSavedObjectResponse(), - id: '1', - attributes: { - assignees: [{ uid: '2' }], - }, - }, - ]; - await service.creator.bulkCreateUpdateCase({ - ...commonArgs, - originalCases: originalCasesWithAssignee, - updatedCases: caseAssignees, - user: commonArgs.user, + builtUserActions: assigneesAddedRemovedUserActions, }); expect(mockAuditLogger.log).toBeCalledTimes(2); @@ -1168,7 +1229,7 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User assigned uids: [2] to case id: 1 - user action id: 0", + "message": undefined, }, ], Array [ @@ -1189,19 +1250,25 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User unassigned uids: [1] from case id: 1 - user action id: 1", + "message": undefined, }, ], ] `); }); + const mockTagsAddedRemovedUserActions = getTagsAddedRemovedUserActions({ + isMock: true, + }); + const tagsAddedRemovedUserActions = Object.keys(mockTagsAddedRemovedUserActions).reduce< + UserActionEvent[] + >((acc, key) => { + return [...acc, ...mockTagsAddedRemovedUserActions[key]]; + }, []); + it('creates the correct user actions when tags are added and removed', async () => { await service.creator.bulkCreateUpdateCase({ - ...commonArgs, - originalCases, - updatedCases: updatedTagsCases, - user: commonArgs.user, + builtUserActions: tagsAddedRemovedUserActions, }); expect(unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]).toMatchInlineSnapshot(` @@ -1270,10 +1337,7 @@ describe('CaseUserActionService', () => { it('logs the correct user actions when tags are added and removed', async () => { await service.creator.bulkCreateUpdateCase({ - ...commonArgs, - originalCases, - updatedCases: updatedTagsCases, - user: commonArgs.user, + builtUserActions: tagsAddedRemovedUserActions, }); expect(mockAuditLogger.log).toBeCalledTimes(2); @@ -1297,7 +1361,7 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User added tags to case id: 1 - user action id: 0", + "message": undefined, }, ], Array [ @@ -1318,7 +1382,7 @@ describe('CaseUserActionService', () => { "type": "cases", }, }, - "message": "User deleted tags in case id: 1 - user action id: 1", + "message": undefined, }, ], ] diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts index 51188a6ff489b..939fb843758d1 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.ts @@ -28,6 +28,7 @@ import type { ConnectorActivityAggsResult, ConnectorFieldsBeforePushAggsResult, GetUsersResponse, + MultipleCasesUserActionsTotalAggsResult, ParticipantsAggsResult, PushInfo, PushTimeFrameInfo, @@ -652,6 +653,55 @@ export class CaseUserActionService { }; } + public async getMultipleCasesUserActionsTotal({ + caseIds, + }: { + caseIds: string[]; + }): Promise> { + const response = await this.context.unsecuredSavedObjectsClient.find< + unknown, + MultipleCasesUserActionsTotalAggsResult + >({ + type: CASE_USER_ACTION_SAVED_OBJECT, + hasReference: caseIds.map((id) => ({ type: CASE_SAVED_OBJECT, id })), + hasReferenceOperator: 'OR', + page: 1, + perPage: 1, + sortField: defaultSortField, + aggs: CaseUserActionService.buildMultipleCasesUserActionsTotalAgg(caseIds.length), + }); + + const result: Record = {}; + + response?.aggregations?.references.caseUserActions.buckets.forEach( + ({ key, doc_count: totalUserActions }: { key: string; doc_count: number }) => { + result[key] = totalUserActions; + } + ); + + return result; + } + + private static buildMultipleCasesUserActionsTotalAgg( + idsLength: number + ): Record { + return { + references: { + nested: { + path: `${CASE_USER_ACTION_SAVED_OBJECT}.references`, + }, + aggregations: { + caseUserActions: { + terms: { + field: `${CASE_USER_ACTION_SAVED_OBJECT}.references.id`, + size: idsLength, + }, + }, + }, + }, + }; + } + public async getCaseUserActionStats({ caseId }: { caseId: string }) { const response = await this.context.unsecuredSavedObjectsClient.find< unknown, diff --git a/x-pack/plugins/cases/server/services/user_actions/mocks.ts b/x-pack/plugins/cases/server/services/user_actions/mocks.ts index b3aecb676d296..41689cc785324 100644 --- a/x-pack/plugins/cases/server/services/user_actions/mocks.ts +++ b/x-pack/plugins/cases/server/services/user_actions/mocks.ts @@ -11,6 +11,7 @@ import type { CasePostRequest } from '../../../common/types/api'; import { createCaseSavedObjectResponse } from '../test_utils'; import { transformSavedObjectToExternalModel } from '../cases/transform'; import { alertComment, comment } from '../../mocks'; +import type { UserActionsDict } from './types'; import { CaseSeverity, CaseStatuses, ConnectorTypes } from '../../../common/types/domain'; export const casePayload: CasePostRequest = { @@ -53,57 +54,648 @@ export const originalCases = [ { ...createCaseSavedObjectResponse(), id: '2' }, ].map((so) => transformSavedObjectToExternalModel(so)); -export const updatedCases = [ - { - ...createCaseSavedObjectResponse(), - id: '1', - type: CASE_SAVED_OBJECT, - attributes: { - title: 'updated title', - status: CaseStatuses.closed, - connector: casePayload.connector, - }, - references: [], - }, - { - ...createCaseSavedObjectResponse(), - id: '2', - type: CASE_SAVED_OBJECT, - attributes: { - description: 'updated desc', - tags: ['one', 'two'], - settings: { syncAlerts: false }, - severity: CaseSeverity.CRITICAL, - }, - references: [], - }, -]; +export const patchCasesRequest = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + type: CASE_SAVED_OBJECT, + updatedAttributes: { + title: 'updated title', + status: CaseStatuses.closed, + connector: casePayload.connector, + category: 'pizza toppings', + }, + originalCase: originalCases[0], + references: [], + }, + { + ...createCaseSavedObjectResponse(), + caseId: '2', + type: CASE_SAVED_OBJECT, + updatedAttributes: { + description: 'updated desc', + tags: ['one', 'two'], + settings: { syncAlerts: false }, + severity: CaseSeverity.CRITICAL, + }, + originalCase: originalCases[1], + references: [], + }, + ], +}; -export const originalCasesWithAssignee = [ +const originalCasesWithAssignee = [ { ...createCaseSavedObjectResponse({ overrides: { assignees: [{ uid: '1' }] } }), id: '1' }, ].map((so) => transformSavedObjectToExternalModel(so)); -export const updatedAssigneesCases = [ - { - ...createCaseSavedObjectResponse(), - id: '1', - attributes: { - assignees: [{ uid: '1' }], +export const patchAssigneesCasesRequest = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + assignees: [{ uid: '1' }], + }, + originalCase: originalCases[0], }, - }, -]; + ], +}; -export const updatedTagsCases = [ - { - ...createCaseSavedObjectResponse(), - id: '1', - attributes: { - tags: ['a', 'b'], +export const patchRemoveAssigneesCasesRequest = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + assignees: [], + }, + originalCase: originalCasesWithAssignee[0], }, - }, -]; + ], +}; + +export const patchAddRemoveAssigneesCasesRequest = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + assignees: [{ uid: '2' }], + }, + originalCase: originalCasesWithAssignee[0], + }, + ], +}; + +export const patchTagsCasesRequest = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + tags: ['a', 'b'], + }, + originalCase: originalCases[0], + }, + ], +}; export const attachments = [ { id: '1', attachment: { ...comment }, owner: SECURITY_SOLUTION_OWNER }, { id: '2', attachment: { ...alertComment }, owner: SECURITY_SOLUTION_OWNER }, ]; + +export const getBuiltUserActions = ({ isMock }: { isMock: boolean }): UserActionsDict => ({ + '1': [ + { + eventDetails: { + action: 'update', + descriptiveAction: 'case_user_action_update_case_title', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '1', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'update', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + title: 'updated title', + }, + type: 'title', + }, + references: [ + { + id: '1', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + { + eventDetails: { + action: 'update', + descriptiveAction: 'case_user_action_update_case_status', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '1', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'update', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + status: 'closed', + }, + type: 'status', + }, + references: [ + { + id: '1', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + { + eventDetails: { + action: 'update', + descriptiveAction: 'case_user_action_update_case_connector', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '1', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'update', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + connector: { + fields: { + category: 'Denial of Service', + destIp: true, + malwareHash: true, + malwareUrl: true, + priority: '2', + sourceIp: true, + subcategory: '45', + }, + name: 'ServiceNow SN', + type: '.servicenow-sir', + }, + }, + type: 'connector', + }, + references: [ + { + id: '1', + name: 'associated-cases', + type: 'cases', + }, + { + id: '456', + name: 'connectorId', + type: 'action', + }, + ], + }, + }, + { + eventDetails: { + action: 'update', + descriptiveAction: 'case_user_action_update_case_category', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '1', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'update', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + category: 'pizza toppings', + }, + type: 'category', + }, + references: [ + { + id: '1', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + ], + '2': [ + { + eventDetails: { + action: 'update', + descriptiveAction: 'case_user_action_update_case_description', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '2', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'update', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + description: 'updated desc', + }, + type: 'description', + }, + references: [ + { + id: '2', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + { + eventDetails: { + action: 'add', + descriptiveAction: 'case_user_action_add_case_tags', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '2', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'add', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + tags: ['one', 'two'], + }, + type: 'tags', + }, + references: [ + { + id: '2', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + { + eventDetails: { + action: 'delete', + descriptiveAction: 'case_user_action_delete_case_tags', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '2', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'delete', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + tags: ['defacement'], + }, + type: 'tags', + }, + references: [ + { + id: '2', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + { + eventDetails: { + action: 'update', + descriptiveAction: 'case_user_action_update_case_settings', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '2', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'update', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + settings: { + syncAlerts: false, + }, + }, + type: 'settings', + }, + references: [ + { + id: '2', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + { + eventDetails: { + action: 'update', + descriptiveAction: 'case_user_action_update_case_severity', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '2', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'update', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + severity: 'critical', + }, + type: 'severity', + }, + references: [ + { + id: '2', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + ], +}); + +export const getAssigneesAddedUserActions = ({ isMock }: { isMock: boolean }): UserActionsDict => ({ + '1': [ + { + eventDetails: { + action: 'add', + descriptiveAction: 'case_user_action_add_case_assignees', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '1', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'add', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + assignees: [ + { + uid: '1', + }, + ], + }, + type: 'assignees', + }, + references: [ + { + id: '1', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + ], +}); + +export const getAssigneesRemovedUserActions = ({ + isMock, +}: { + isMock: boolean; +}): UserActionsDict => ({ + '1': [ + { + eventDetails: { + action: 'delete', + descriptiveAction: 'case_user_action_delete_case_assignees', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '1', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'delete', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + assignees: [ + { + uid: '1', + }, + ], + }, + type: 'assignees', + }, + references: [ + { + id: '1', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + ], +}); + +export const getAssigneesAddedRemovedUserActions = ({ + isMock, +}: { + isMock: boolean; +}): UserActionsDict => ({ + '1': [ + { + eventDetails: { + action: 'add', + descriptiveAction: 'case_user_action_add_case_assignees', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '1', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'add', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + assignees: [ + { + uid: '2', + }, + ], + }, + type: 'assignees', + }, + references: [ + { + id: '1', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + { + eventDetails: { + action: 'delete', + descriptiveAction: 'case_user_action_delete_case_assignees', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '1', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'delete', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + assignees: [ + { + uid: '1', + }, + ], + }, + type: 'assignees', + }, + references: [ + { + id: '1', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + ], +}); + +export const getTagsAddedRemovedUserActions = ({ + isMock, +}: { + isMock: boolean; +}): UserActionsDict => ({ + '1': [ + { + eventDetails: { + action: 'add', + descriptiveAction: 'case_user_action_add_case_tags', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '1', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'add', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + tags: ['a', 'b'], + }, + type: 'tags', + }, + references: [ + { + id: '1', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + { + eventDetails: { + action: 'delete', + descriptiveAction: 'case_user_action_delete_case_tags', + getMessage: isMock ? jest.fn() : expect.any(Function), + savedObjectId: '1', + savedObjectType: 'cases', + }, + parameters: { + attributes: { + action: 'delete', + created_at: '2022-01-09T22:00:00.000Z', + created_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'securitySolution', + payload: { + tags: ['defacement'], + }, + type: 'tags', + }, + references: [ + { + id: '1', + name: 'associated-cases', + type: 'cases', + }, + ], + }, + }, + ], +}); diff --git a/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts b/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts index 92f5a6b6c254b..e29bb63168ece 100644 --- a/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts @@ -17,6 +17,18 @@ import { UserActionPersister } from './create'; import { createUserActionSO } from '../test_utils'; import type { BulkCreateAttachmentUserAction, CreateUserActionClient } from '../types'; import type { UserActionPersistedAttributes } from '../../../common/types/user_actions'; +import { + getAssigneesAddedRemovedUserActions, + getAssigneesAddedUserActions, + getAssigneesRemovedUserActions, + getBuiltUserActions, + getTagsAddedRemovedUserActions, + patchAddRemoveAssigneesCasesRequest, + patchAssigneesCasesRequest, + patchCasesRequest, + patchRemoveAssigneesCasesRequest, + patchTagsCasesRequest, +} from '../mocks'; import { AttachmentType } from '../../../../common/types/domain'; describe('UserActionPersister', () => { @@ -28,6 +40,11 @@ describe('UserActionPersister', () => { let persister: UserActionPersister; + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2022-01-09T22:00:00.000Z')); + }); + beforeEach(() => { jest.resetAllMocks(); persister = new UserActionPersister({ @@ -39,6 +56,10 @@ describe('UserActionPersister', () => { }); }); + afterAll(() => { + jest.useRealTimers(); + }); + const getRequest = () => ({ action: 'update' as const, @@ -62,6 +83,8 @@ describe('UserActionPersister', () => { user: { email: '', full_name: '', username: '' }, }); + const testUser = { full_name: 'Elastic User', username: 'elastic', email: 'elastic@elastic.co' }; + describe('Decoding requests', () => { describe('createUserAction', () => { beforeEach(() => { @@ -141,4 +164,59 @@ describe('UserActionPersister', () => { }); }); }); + + describe('buildUserActions', () => { + it('creates the correct user actions when bulk updating cases', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchCasesRequest, + user: testUser, + }) + ).toEqual(getBuiltUserActions({ isMock: false })); + }); + + it('creates the correct user actions when an assignee is added', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchAssigneesCasesRequest, + user: testUser, + }) + ).toEqual(getAssigneesAddedUserActions({ isMock: false })); + }); + + it('creates the correct user actions when an assignee is removed', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchRemoveAssigneesCasesRequest, + user: testUser, + }) + ).toEqual(getAssigneesRemovedUserActions({ isMock: false })); + }); + + it('creates the correct user actions when assignees are added and removed', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchAddRemoveAssigneesCasesRequest, + user: testUser, + }) + ).toEqual( + getAssigneesAddedRemovedUserActions({ + isMock: false, + }) + ); + }); + + it('creates the correct user actions when tags are added and removed', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchTagsCasesRequest, + user: testUser, + }) + ).toEqual( + getTagsAddedRemovedUserActions({ + isMock: false, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/cases/server/services/user_actions/operations/create.ts b/x-pack/plugins/cases/server/services/user_actions/operations/create.ts index 5713197a653db..3b9c51e2df24b 100644 --- a/x-pack/plugins/cases/server/services/user_actions/operations/create.ts +++ b/x-pack/plugins/cases/server/services/user_actions/operations/create.ts @@ -22,6 +22,7 @@ import { isUserActionType } from '../../../../common/utils/user_actions'; import { decodeOrThrow } from '../../../../common/api'; import { BuilderFactory } from '../builder_factory'; import type { + BuildUserActionsDictParams, BuilderParameters, BulkCreateAttachmentUserAction, BulkCreateBulkUpdateCaseUserActions, @@ -34,6 +35,7 @@ import type { ServiceContext, TypedUserActionDiffedItems, UserActionEvent, + UserActionsDict, } from '../types'; import { isAssigneesArray, isStringArray } from '../type_guards'; import type { IndexRefresh } from '../../types'; @@ -55,30 +57,25 @@ export class UserActionPersister { this.auditLogger = new UserActionAuditLogger(this.context.auditLogger); } - public async bulkCreateUpdateCase({ - originalCases, - updatedCases, - user, - refresh, - }: BulkCreateBulkUpdateCaseUserActions): Promise { - const builtUserActions = updatedCases.reduce((acc, updatedCase) => { - const originalCase = originalCases.find(({ id }) => id === updatedCase.id); + public buildUserActions({ updatedCases, user }: BuildUserActionsDictParams): UserActionsDict { + return updatedCases.cases.reduce((acc, updatedCase) => { + const originalCase = updatedCase.originalCase; if (originalCase == null) { return acc; } - const caseId = updatedCase.id; + const caseId = updatedCase.caseId; const owner = originalCase.attributes.owner; const userActions: UserActionEvent[] = []; - const updatedFields = Object.keys(updatedCase.attributes); + const updatedFields = Object.keys(updatedCase.updatedAttributes); updatedFields .filter((field) => UserActionPersister.userActionFieldsAllowed.has(field)) .forEach((field) => { const originalValue = get(originalCase, ['attributes', field]); - const newValue = get(updatedCase, ['attributes', field]); + const newValue = get(updatedCase, ['updatedAttributes', field]); userActions.push( ...this.getUserActionItemByDifference({ field, @@ -91,9 +88,15 @@ export class UserActionPersister { ); }); - return [...acc, ...userActions]; - }, []); + acc[caseId] = userActions; + return acc; + }, {}); + } + public async bulkCreateUpdateCase({ + builtUserActions, + refresh, + }: BulkCreateBulkUpdateCaseUserActions): Promise { await this.bulkCreateAndLog({ userActions: builtUserActions, refresh, @@ -368,6 +371,7 @@ export class UserActionPersister { userAction: UserActionEvent; } & IndexRefresh): Promise { const createdUserAction = await this.create({ ...userAction.parameters, refresh }); + this.auditLogger.log(userAction.eventDetails, createdUserAction.id); } @@ -381,7 +385,7 @@ export class UserActionPersister { const decodedAttributes = decodeOrThrow(UserActionPersistedAttributesRt)(attributes); - return await this.context.unsecuredSavedObjectsClient.create( + const res = await this.context.unsecuredSavedObjectsClient.create( CASE_USER_ACTION_SAVED_OBJECT, decodedAttributes as unknown as T, { @@ -389,6 +393,7 @@ export class UserActionPersister { refresh, } ); + return res; } catch (error) { this.context.log.error(`Error on POST a new case user action: ${error}`); throw error; diff --git a/x-pack/plugins/cases/server/services/user_actions/types.ts b/x-pack/plugins/cases/server/services/user_actions/types.ts index 68e60fe6ccc0a..657fc31d7275b 100644 --- a/x-pack/plugins/cases/server/services/user_actions/types.ts +++ b/x-pack/plugins/cases/server/services/user_actions/types.ts @@ -11,7 +11,6 @@ import type { Logger, ISavedObjectsSerializer, SavedObjectsRawDoc, - SavedObjectsUpdateResponse, } from '@kbn/core/server'; import type { KueryNode } from '@kbn/es-query'; import type { AuditLogger } from '@kbn/security-plugin/server'; @@ -22,7 +21,6 @@ import type { ConnectorUserAction, PushedUserAction, UserActionType, - CaseAttributes, CaseSettings, CaseSeverity, CaseStatuses, @@ -35,7 +33,7 @@ import type { UserActionSavedObjectTransformed, } from '../../common/types/user_actions'; import type { IndexRefresh } from '../types'; -import type { CaseSavedObjectTransformed } from '../../common/types/case'; +import type { PatchCasesArgs } from '../cases/types'; import type { AttachmentRequest, CasePostRequest, @@ -237,6 +235,17 @@ export interface UserActionsStatsAggsResult { }; } +export interface MultipleCasesUserActionsTotalAggsResult { + references: { + caseUserActions: { + buckets: Array<{ + key: string; + doc_count: number; + }>; + }; + }; +} + export interface ParticipantsAggsResult { participants: { buckets: Array<{ @@ -282,12 +291,17 @@ export type CreatePayloadFunction = ( items: Item[] ) => UserActionParameters['payload']; -export interface BulkCreateBulkUpdateCaseUserActions extends IndexRefresh { - originalCases: CaseSavedObjectTransformed[]; - updatedCases: Array>; +export interface BuildUserActionsDictParams { + updatedCases: PatchCasesArgs; user: User; } +export type UserActionsDict = Record; + +export interface BulkCreateBulkUpdateCaseUserActions extends IndexRefresh { + builtUserActions: UserActionEvent[]; +} + export interface BulkCreateAttachmentUserAction extends Omit, IndexRefresh {