[Cases] Total number of user actions on a case. (#161848)

Connected to https://github.com/elastic/kibana/issues/146945

## Summary

| Description  | Limit | Done? | Documented?
| ------------- | ---- | :---: | ---- |
| Total number of user actions and comments combined on a case | 10000 |
 | No |

### Checklist

Delete any items that are not applicable to this PR.

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### Release Notes

Updating a case will now fail if the operation makes it reach more than
10000 user actions.
This commit is contained in:
Antonio 2023-07-27 16:41:36 +02:00 committed by GitHub
parent a487ad77bd
commit 33195fb7df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1285 additions and 221 deletions

View file

@ -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;
/**

View file

@ -7,3 +7,4 @@
export * from './connectors_api';
export * from './capabilities';
export * from './validators';

View file

@ -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();
});
});
});

View file

@ -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.`
);
}
};

View file

@ -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.`
);
});
});

View file

@ -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();

View file

@ -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.'
);

View file

@ -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);

View file

@ -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.`
);
});
});
});
});

View file

@ -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<UserActionEvent[]>((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 = (

View file

@ -97,6 +97,7 @@ const createUserActionPersisterServiceMock = (): CaseUserActionPersisterServiceM
const service: PublicMethodsOf<UserActionPersister> = {
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(),
};

View file

@ -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<UserActionEvent[]>(
(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<SavedObjectsUpdateResponse<CaseAttributes>> = [
{
...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<SavedObjectsUpdateResponse<CaseAttributes>> = [
{
...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<SavedObjectsUpdateResponse<CaseAttributes>> = [
{
...createCaseSavedObjectResponse(),
id: '1',
attributes: {
assignees: [{ uid: '2' }],
},
},
];
const mockAssigneesAddedRemovedUserActions = getAssigneesAddedRemovedUserActions({
isMock: true,
});
const assigneesAddedRemovedUserActions = Object.keys(
mockAssigneesAddedRemovedUserActions
).reduce<UserActionEvent[]>((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<SavedObjectsUpdateResponse<CaseAttributes>> = [
{
...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,
},
],
]

View file

@ -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<Record<string, number>> {
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<string, number> = {};
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<string, estypes.AggregationsAggregationContainer> {
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,

View file

@ -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,
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: [],
},
references: [],
},
{
...createCaseSavedObjectResponse(),
id: '2',
type: CASE_SAVED_OBJECT,
attributes: {
description: 'updated desc',
tags: ['one', 'two'],
settings: { syncAlerts: false },
severity: CaseSeverity.CRITICAL,
{
...createCaseSavedObjectResponse(),
caseId: '2',
type: CASE_SAVED_OBJECT,
updatedAttributes: {
description: 'updated desc',
tags: ['one', 'two'],
settings: { syncAlerts: false },
severity: CaseSeverity.CRITICAL,
},
originalCase: originalCases[1],
references: [],
},
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',
},
],
},
},
],
});

View file

@ -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,
})
);
});
});
});

View file

@ -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<void> {
const builtUserActions = updatedCases.reduce<UserActionEvent[]>((acc, updatedCase) => {
const originalCase = originalCases.find(({ id }) => id === updatedCase.id);
public buildUserActions({ updatedCases, user }: BuildUserActionsDictParams): UserActionsDict {
return updatedCases.cases.reduce<UserActionsDict>((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<void> {
await this.bulkCreateAndLog({
userActions: builtUserActions,
refresh,
@ -368,6 +371,7 @@ export class UserActionPersister {
userAction: UserActionEvent;
} & IndexRefresh): Promise<void> {
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<T>(
const res = await this.context.unsecuredSavedObjectsClient.create<T>(
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;

View file

@ -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<Item, ActionType extends UserActionType> = (
items: Item[]
) => UserActionParameters<ActionType>['payload'];
export interface BulkCreateBulkUpdateCaseUserActions extends IndexRefresh {
originalCases: CaseSavedObjectTransformed[];
updatedCases: Array<SavedObjectsUpdateResponse<CaseAttributes>>;
export interface BuildUserActionsDictParams {
updatedCases: PatchCasesArgs;
user: User;
}
export type UserActionsDict = Record<string, UserActionEvent[]>;
export interface BulkCreateBulkUpdateCaseUserActions extends IndexRefresh {
builtUserActions: UserActionEvent[];
}
export interface BulkCreateAttachmentUserAction
extends Omit<CommonUserActionArgs, 'owner'>,
IndexRefresh {