[Cases] Filter out alerts already attached to the case on the backend (#154629)

## Summary

This PR filters out alerts already attached to a case on the backend.
The schema of the alerts in a case is:

```
{
  alertId: string | string[]
  index: string | string[]
}
```

So the `alertId` can contain multiple alert ids. In this case, if an ID
is attached to the case it will be removed from the `alertId` array. If
all alerts are attach to the case no attachment will be created.

### Checklist

Delete any items that are not applicable to this PR.

- [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

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Christos Nasikas 2023-04-12 18:59:48 +03:00 committed by GitHub
parent c3e6e70428
commit bb73249d81
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 1050 additions and 15 deletions

View file

@ -0,0 +1,551 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AttributesTypeAlerts } from '../../../common/api';
import type { SavedObject } from '@kbn/core-saved-objects-api-server';
import { CommentType, SECURITY_SOLUTION_OWNER } from '../../../common';
import { createCasesClientMockArgs } from '../../client/mocks';
import { mockCaseComments, mockCases } from '../../mocks';
import { CaseCommentModel } from './case_with_comments';
describe('CaseCommentModel', () => {
const theCase = mockCases[0];
const clientArgs = createCasesClientMockArgs();
const createdDate = '2023-04-07T12:18:36.941Z';
const userComment = {
comment: 'Wow, good luck catching that bad meanie!',
type: CommentType.user as const,
owner: SECURITY_SOLUTION_OWNER,
};
const singleAlert = {
type: CommentType.alert as const,
owner: SECURITY_SOLUTION_OWNER,
alertId: 'test-id-1',
index: 'test-index-1',
rule: {
id: 'rule-id-1',
name: 'rule-name-1',
},
};
const multipleAlert = {
...singleAlert,
alertId: ['test-id-3', 'test-id-4', 'test-id-5'],
index: ['test-index-3', 'test-index-4', 'test-index-5'],
};
clientArgs.services.caseService.getCase.mockResolvedValue(theCase);
clientArgs.services.caseService.patchCase.mockResolvedValue(theCase);
clientArgs.services.attachmentService.create.mockResolvedValue(mockCaseComments[0]);
clientArgs.services.attachmentService.bulkCreate.mockResolvedValue({
saved_objects: mockCaseComments,
});
const alertIdsAttachedToCase = new Set(['test-id-4']);
clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValue(
alertIdsAttachedToCase
);
let model: CaseCommentModel;
beforeAll(async () => {
model = await CaseCommentModel.create(theCase.id, clientArgs);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('create', () => {
it('does not remove comments when filtering out duplicate alerts', async () => {
await model.createComment({
id: 'comment-1',
commentReq: userComment,
createdDate,
});
expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"attributes": Object {
"comment": "Wow, good luck catching that bad meanie!",
"created_at": "2023-04-07T12:18:36.941Z",
"created_by": Object {
"email": "damaged_raccoon@elastic.co",
"full_name": "Damaged Raccoon",
"profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0",
"username": "damaged_raccoon",
},
"owner": "securitySolution",
"pushed_at": null,
"pushed_by": null,
"type": "user",
"updated_at": null,
"updated_by": null,
},
"id": "comment-1",
"references": Array [
Object {
"id": "mock-id-1",
"name": "associated-cases",
"type": "cases",
},
],
"refresh": false,
},
],
]
`);
});
it('does not remove alerts not attached to the case', async () => {
await model.createComment({
id: 'comment-1',
commentReq: singleAlert,
createdDate,
});
expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"attributes": Object {
"alertId": Array [
"test-id-1",
],
"created_at": "2023-04-07T12:18:36.941Z",
"created_by": Object {
"email": "damaged_raccoon@elastic.co",
"full_name": "Damaged Raccoon",
"profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0",
"username": "damaged_raccoon",
},
"index": Array [
"test-index-1",
],
"owner": "securitySolution",
"pushed_at": null,
"pushed_by": null,
"rule": Object {
"id": "rule-id-1",
"name": "rule-name-1",
},
"type": "alert",
"updated_at": null,
"updated_by": null,
},
"id": "comment-1",
"references": Array [
Object {
"id": "mock-id-1",
"name": "associated-cases",
"type": "cases",
},
],
"refresh": false,
},
],
]
`);
});
it('remove alerts attached to the case', async () => {
await model.createComment({
id: 'comment-1',
commentReq: multipleAlert,
createdDate,
});
expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"attributes": Object {
"alertId": Array [
"test-id-3",
"test-id-5",
],
"created_at": "2023-04-07T12:18:36.941Z",
"created_by": Object {
"email": "damaged_raccoon@elastic.co",
"full_name": "Damaged Raccoon",
"profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0",
"username": "damaged_raccoon",
},
"index": Array [
"test-index-3",
"test-index-5",
],
"owner": "securitySolution",
"pushed_at": null,
"pushed_by": null,
"rule": Object {
"id": "rule-id-1",
"name": "rule-name-1",
},
"type": "alert",
"updated_at": null,
"updated_by": null,
},
"id": "comment-1",
"references": Array [
Object {
"id": "mock-id-1",
"name": "associated-cases",
"type": "cases",
},
],
"refresh": false,
},
],
]
`);
});
it('remove multiple alerts', async () => {
clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce(
new Set(['test-id-3', 'test-id-5'])
);
await model.createComment({
id: 'comment-1',
commentReq: multipleAlert,
createdDate,
});
expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"attributes": Object {
"alertId": Array [
"test-id-4",
],
"created_at": "2023-04-07T12:18:36.941Z",
"created_by": Object {
"email": "damaged_raccoon@elastic.co",
"full_name": "Damaged Raccoon",
"profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0",
"username": "damaged_raccoon",
},
"index": Array [
"test-index-4",
],
"owner": "securitySolution",
"pushed_at": null,
"pushed_by": null,
"rule": Object {
"id": "rule-id-1",
"name": "rule-name-1",
},
"type": "alert",
"updated_at": null,
"updated_by": null,
},
"id": "comment-1",
"references": Array [
Object {
"id": "mock-id-1",
"name": "associated-cases",
"type": "cases",
},
],
"refresh": false,
},
],
]
`);
});
it('does not create attachments if all alerts are attached to the case', async () => {
clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce(
new Set(['test-id-3', 'test-id-4', 'test-id-5'])
);
await model.createComment({
id: 'comment-1',
commentReq: multipleAlert,
createdDate,
});
expect(clientArgs.services.attachmentService.create).not.toHaveBeenCalled();
});
it('does not create attachments if the alert is attached to the case', async () => {
clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce(
new Set(['test-id-1'])
);
await model.createComment({
id: 'comment-1',
commentReq: singleAlert,
createdDate,
});
expect(clientArgs.services.attachmentService.create).not.toHaveBeenCalled();
});
});
describe('bulkCreate', () => {
it('does not remove user comments when filtering out duplicate alerts', async () => {
await model.bulkCreate({
attachments: [
{
id: 'comment-1',
...userComment,
},
{
id: 'comment-2',
...singleAlert,
},
{
id: 'comment-3',
...multipleAlert,
},
],
});
const attachments =
clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments;
const singleAlertCall = attachments[1] as SavedObject<AttributesTypeAlerts>;
const multipleAlertsCall = attachments[2] as SavedObject<AttributesTypeAlerts>;
expect(attachments.length).toBe(3);
expect(attachments[0].attributes.type).toBe('user');
expect(attachments[1].attributes.type).toBe('alert');
expect(attachments[2].attributes.type).toBe('alert');
expect(singleAlertCall.attributes.alertId).toEqual(['test-id-1']);
expect(singleAlertCall.attributes.index).toEqual(['test-index-1']);
expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']);
expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']);
});
it('does not remove alerts not attached to the case', async () => {
await model.bulkCreate({
attachments: [
{
id: 'comment-1',
...singleAlert,
},
],
});
const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0]
.attachments as Array<SavedObject<AttributesTypeAlerts>>;
expect(attachments.length).toBe(1);
expect(attachments[0].attributes.type).toBe('alert');
expect(attachments[0].attributes.alertId).toEqual(['test-id-1']);
expect(attachments[0].attributes.index).toEqual(['test-index-1']);
});
it('remove alerts attached to the case', async () => {
await model.bulkCreate({
attachments: [
{
id: 'comment-1',
...multipleAlert,
},
],
});
const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0]
.attachments as Array<SavedObject<AttributesTypeAlerts>>;
expect(attachments.length).toBe(1);
expect(attachments[0].attributes.type).toBe('alert');
expect(attachments[0].attributes.alertId).toEqual(['test-id-3', 'test-id-5']);
expect(attachments[0].attributes.index).toEqual(['test-index-3', 'test-index-5']);
});
it('remove multiple alerts', async () => {
clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce(
new Set(['test-id-3', 'test-id-5'])
);
await model.bulkCreate({
attachments: [
{
id: 'comment-1',
...multipleAlert,
},
],
});
const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0]
.attachments as Array<SavedObject<AttributesTypeAlerts>>;
expect(attachments.length).toBe(1);
expect(attachments[0].attributes.type).toBe('alert');
expect(attachments[0].attributes.alertId).toEqual(['test-id-4']);
expect(attachments[0].attributes.index).toEqual(['test-index-4']);
});
it('does not create attachments if all alerts are attached to the case', async () => {
clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce(
new Set(['test-id-3', 'test-id-4', 'test-id-5'])
);
await model.bulkCreate({
attachments: [
{
id: 'comment-1',
...multipleAlert,
},
],
});
expect(clientArgs.services.attachmentService.bulkCreate).not.toHaveBeenCalled();
});
it('does not create attachments if the alert is attached to the case', async () => {
clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce(
new Set(['test-id-1'])
);
await model.createComment({
id: 'comment-1',
commentReq: singleAlert,
createdDate,
});
expect(clientArgs.services.attachmentService.bulkCreate).not.toHaveBeenCalled();
});
it('remove alerts from multiple attachments', async () => {
await model.bulkCreate({
attachments: [
{
id: 'comment-1',
...userComment,
},
{
id: 'comment-2',
...singleAlert,
},
{
id: 'comment-3',
...singleAlert,
},
{
id: 'comment-4',
...multipleAlert,
},
{
id: 'comment-5',
...multipleAlert,
},
],
});
const attachments =
clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments;
const singleAlertCall = attachments[1] as SavedObject<AttributesTypeAlerts>;
const multipleAlertsCall = attachments[2] as SavedObject<AttributesTypeAlerts>;
expect(attachments.length).toBe(3);
expect(attachments[0].attributes.type).toBe('user');
expect(attachments[1].attributes.type).toBe('alert');
expect(attachments[2].attributes.type).toBe('alert');
expect(singleAlertCall.attributes.alertId).toEqual(['test-id-1']);
expect(singleAlertCall.attributes.index).toEqual(['test-index-1']);
expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']);
expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']);
});
it('remove alerts from multiple attachments on the same request', async () => {
await model.bulkCreate({
attachments: [
{
id: 'comment-1',
...userComment,
},
{
id: 'comment-2',
...singleAlert,
},
{
id: 'comment-3',
...multipleAlert,
alertId: ['test-id-1', 'test-id-2'],
index: ['test-index-1', 'test-index-2'],
},
{
id: 'comment-4',
...multipleAlert,
alertId: ['test-id-2', 'test-id-4', 'test-id-5'],
index: ['test-index-1', 'test-index-4', 'test-index-5'],
},
],
});
const attachments =
clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments;
const alertOne = attachments[1] as SavedObject<AttributesTypeAlerts>;
const alertTwo = attachments[2] as SavedObject<AttributesTypeAlerts>;
const alertThree = attachments[3] as SavedObject<AttributesTypeAlerts>;
expect(attachments.length).toBe(4);
expect(attachments[0].attributes.type).toBe('user');
expect(attachments[1].attributes.type).toBe('alert');
expect(attachments[2].attributes.type).toBe('alert');
expect(attachments[3].attributes.type).toBe('alert');
expect(alertOne.attributes.alertId).toEqual(['test-id-1']);
expect(alertOne.attributes.index).toEqual(['test-index-1']);
expect(alertTwo.attributes.alertId).toEqual(['test-id-2']);
expect(alertTwo.attributes.index).toEqual(['test-index-2']);
expect(alertThree.attributes.alertId).toEqual(['test-id-5']);
expect(alertThree.attributes.index).toEqual(['test-index-5']);
});
it('remove alerts from multiple attachments with multiple alerts attached to the case', async () => {
clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce(
new Set(['test-id-1', 'test-id-4'])
);
await model.bulkCreate({
attachments: [
{
id: 'comment-1',
...userComment,
},
{
id: 'comment-2',
...singleAlert,
},
{
id: 'comment-3',
...multipleAlert,
},
],
});
const attachments =
clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments;
const multipleAlertsCall = attachments[1] as SavedObject<AttributesTypeAlerts>;
expect(attachments.length).toBe(2);
expect(attachments[0].attributes.type).toBe('user');
expect(attachments[1].attributes.type).toBe('alert');
expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']);
expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']);
});
});
});

View file

@ -40,9 +40,11 @@ import {
getOrUpdateLensReferences,
isCommentRequestTypeAlert,
getAlertInfoFromComments,
getIDsAndIndicesAsArrays,
} from '../utils';
type CaseCommentModelParams = Omit<CasesClientArgs, 'authorization'>;
type CommentRequestWithId = Array<{ id: string } & CommentRequest>;
/**
* This class represents a case that can have a comment attached to it.
@ -213,14 +215,23 @@ export class CaseCommentModel {
}): Promise<CaseCommentModel> {
try {
await this.validateCreateCommentRequest([commentReq]);
const attachmentsWithoutDuplicateAlerts = await this.filterDuplicatedAlerts([
{ ...commentReq, id },
]);
const references = [...this.buildRefsToCase(), ...this.getCommentReferences(commentReq)];
if (attachmentsWithoutDuplicateAlerts.length === 0) {
return this;
}
const { id: commentId, ...attachment } = attachmentsWithoutDuplicateAlerts[0];
const references = [...this.buildRefsToCase(), ...this.getCommentReferences(attachment)];
const [comment, commentableCase] = await Promise.all([
this.params.services.attachmentService.create({
attributes: transformNewComment({
createdDate,
...commentReq,
...attachment,
...this.params.user,
}),
references,
@ -231,8 +242,8 @@ export class CaseCommentModel {
]);
await Promise.all([
commentableCase.handleAlertComments([commentReq]),
this.createCommentUserAction(comment, commentReq),
commentableCase.handleAlertComments([attachment]),
this.createCommentUserAction(comment, attachment),
]);
return commentableCase;
@ -245,8 +256,73 @@ export class CaseCommentModel {
}
}
private async filterDuplicatedAlerts(
attachments: CommentRequestWithId
): Promise<CommentRequestWithId> {
/**
* This function removes the elements in items that exist at the passed in positions.
*/
const removeItemsByPosition = (items: string[], positionsToRemove: number[]): string[] =>
items.filter((_, itemIndex) => !positionsToRemove.some((position) => position === itemIndex));
const dedupedAlertAttachments: CommentRequestWithId = [];
const idsAlreadySeen = new Set();
const alertsAttachedToCase = await this.params.services.attachmentService.getter.getAllAlertIds(
{
caseId: this.caseInfo.id,
}
);
attachments.forEach((attachment) => {
if (!isCommentRequestTypeAlert(attachment)) {
dedupedAlertAttachments.push(attachment);
return;
}
const { ids, indices } = getIDsAndIndicesAsArrays(attachment);
const idPositionsThatAlreadyExistInCase: number[] = [];
ids.forEach((id, index) => {
if (alertsAttachedToCase.has(id) || idsAlreadySeen.has(id)) {
idPositionsThatAlreadyExistInCase.push(index);
}
idsAlreadySeen.add(id);
});
const alertIdsNotAlreadyAttachedToCase = removeItemsByPosition(
ids,
idPositionsThatAlreadyExistInCase
);
const alertIndicesNotAlreadyAttachedToCase = removeItemsByPosition(
indices,
idPositionsThatAlreadyExistInCase
);
if (
alertIdsNotAlreadyAttachedToCase.length > 0 &&
alertIdsNotAlreadyAttachedToCase.length === alertIndicesNotAlreadyAttachedToCase.length
) {
dedupedAlertAttachments.push({
...attachment,
alertId: alertIdsNotAlreadyAttachedToCase,
index: alertIndicesNotAlreadyAttachedToCase,
});
}
});
return dedupedAlertAttachments;
}
private getAlertAttachments(attachments: CommentRequest[]): CommentRequestAlertType[] {
return attachments.filter(
(attachment): attachment is CommentRequestAlertType => attachment.type === CommentType.alert
);
}
private async validateCreateCommentRequest(req: CommentRequest[]) {
const hasAlertsInRequest = req.some((request) => isCommentRequestTypeAlert(request));
const alertAttachments = this.getAlertAttachments(req);
const hasAlertsInRequest = alertAttachments.length > 0;
if (hasAlertsInRequest && this.caseInfo.attributes.status === CaseStatuses.closed) {
throw Boom.badRequest('Alert cannot be attached to a closed case');
@ -290,9 +366,7 @@ export class CaseCommentModel {
}
private async handleAlertComments(attachments: CommentRequest[]) {
const alertAttachments = attachments.filter(
(attachment): attachment is CommentRequestAlertType => attachment.type === CommentType.alert
);
const alertAttachments = this.getAlertAttachments(attachments);
const alerts = getAlertInfoFromComments(alertAttachments);
@ -392,16 +466,22 @@ export class CaseCommentModel {
public async bulkCreate({
attachments,
}: {
attachments: Array<{ id: string } & CommentRequest>;
attachments: CommentRequestWithId;
}): Promise<CaseCommentModel> {
try {
await this.validateCreateCommentRequest(attachments);
const attachmentWithoutDuplicateAlerts = await this.filterDuplicatedAlerts(attachments);
if (attachmentWithoutDuplicateAlerts.length === 0) {
return this;
}
const caseReference = this.buildRefsToCase();
const [newlyCreatedAttachments, commentableCase] = await Promise.all([
this.params.services.attachmentService.bulkCreate({
attachments: attachments.map(({ id, ...attachment }) => {
attachments: attachmentWithoutDuplicateAlerts.map(({ id, ...attachment }) => {
return {
attributes: transformNewComment({
createdDate: new Date().toISOString(),

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import { loggerMock } from '@kbn/logging-mocks';
import { createPersistableStateAttachmentTypeRegistryMock } from '../../../attachment_framework/mocks';
import { AttachmentGetter } from './get';
describe('AttachmentService getter', () => {
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
const mockLogger = loggerMock.create();
const persistableStateAttachmentTypeRegistry = createPersistableStateAttachmentTypeRegistryMock();
let attachmentGetter: AttachmentGetter;
beforeEach(async () => {
jest.clearAllMocks();
attachmentGetter = new AttachmentGetter({
log: mockLogger,
persistableStateAttachmentTypeRegistry,
unsecuredSavedObjectsClient,
});
});
describe('getAllAlertIds', () => {
const aggsRes = {
aggregations: { alertIds: { buckets: [{ key: 'alert-id-1' }, { key: 'alert-id-2' }] } },
saved_objects: [],
page: 1,
per_page: 0,
total: 0,
};
unsecuredSavedObjectsClient.find.mockResolvedValue(aggsRes);
const caseId = 'test-case';
it('returns the alert ids correctly', async () => {
const res = await attachmentGetter.getAllAlertIds({ caseId });
expect(Array.from(res.values())).toEqual(['alert-id-1', 'alert-id-2']);
});
it('calls find with correct arguments', async () => {
await attachmentGetter.getAllAlertIds({ caseId });
expect(unsecuredSavedObjectsClient.find.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
Object {
"aggs": Object {
"alertIds": Object {
"terms": Object {
"field": "cases-comments.attributes.alertId",
"size": 1000,
},
},
},
"filter": Object {
"arguments": Array [
Object {
"isQuoted": false,
"type": "literal",
"value": "cases-comments.attributes.type",
},
Object {
"isQuoted": false,
"type": "literal",
"value": "alert",
},
],
"function": "is",
"type": "function",
},
"hasReference": Object {
"id": "test-case",
"type": "cases",
},
"perPage": 0,
"sortField": "created_at",
"sortOrder": "asc",
"type": "cases-comments",
},
],
]
`);
});
it('returns an empty set when there is no response', async () => {
// @ts-expect-error
unsecuredSavedObjectsClient.find.mockResolvedValue({});
const res = await attachmentGetter.getAllAlertIds({ caseId });
expect(Array.from(res.values())).toEqual([]);
});
it('remove duplicate keys', async () => {
unsecuredSavedObjectsClient.find.mockResolvedValue({
...aggsRes,
aggregations: { alertIds: { buckets: [{ key: 'alert-id-1' }, { key: 'alert-id-1' }] } },
});
const res = await attachmentGetter.getAllAlertIds({ caseId });
expect(Array.from(res.values())).toEqual(['alert-id-1']);
});
});
});

View file

@ -11,6 +11,7 @@ import { FILE_SO_TYPE } from '@kbn/files-plugin/common';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
MAX_ALERTS_PER_CASE,
MAX_DOCS_PER_PAGE,
} from '../../../../common/constants';
import { buildFilter, combineFilters } from '../../../client/utils';
@ -38,6 +39,14 @@ import { getCaseReferenceId } from '../../../common/references';
type GetAllAlertsAttachToCaseArgs = AttachedToCaseArgs;
interface AlertIdsAggsResult {
alertIds: {
buckets: Array<{
key: string;
}>;
};
}
export class AttachmentGetter {
constructor(private readonly context: ServiceContext) {}
@ -145,6 +154,44 @@ export class AttachmentGetter {
}
}
/**
* Retrieves all the alerts attached to a case.
*/
public async getAllAlertIds({ caseId }: { caseId: string }): Promise<Set<string>> {
try {
this.context.log.debug(`Attempting to GET all alerts ids for case id ${caseId}`);
const alertsFilter = buildFilter({
filters: [CommentType.alert],
field: 'type',
operator: 'or',
type: CASE_COMMENT_SAVED_OBJECT,
});
const res = await this.context.unsecuredSavedObjectsClient.find<unknown, AlertIdsAggsResult>({
type: CASE_COMMENT_SAVED_OBJECT,
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
sortField: 'created_at',
sortOrder: 'asc',
filter: alertsFilter,
perPage: 0,
aggs: {
alertIds: {
terms: {
field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`,
size: MAX_ALERTS_PER_CASE,
},
},
},
});
const alertIds = res.aggregations?.alertIds.buckets.map((bucket) => bucket.key) ?? [];
return new Set(alertIds);
} catch (error) {
this.context.log.error(`Error on GET all alerts ids for case id ${caseId}: ${error}`);
throw error;
}
}
public async get({
attachmentId,
}: GetAttachmentArgs): Promise<SavedObject<AttachmentAttributes>> {

View file

@ -154,6 +154,7 @@ const createAttachmentGetterServiceMock = (): AttachmentGetterServiceMock => {
getCaseCommentStats: jest.fn(),
getAttachmentIdsForCases: jest.fn(),
getFileAttachments: jest.fn(),
getAllAlertIds: jest.fn(),
};
return service as unknown as AttachmentGetterServiceMock;

View file

@ -127,7 +127,7 @@ export const createCaseAndBulkCreateAttachments = async ({
};
export const getAttachments = (numberOfAttachments: number): BulkCreateCommentRequest => {
return [...Array(numberOfAttachments)].map((index) => {
return [...Array(numberOfAttachments)].map((_, index) => {
if (index % 0) {
return {
type: CommentType.user,
@ -138,8 +138,8 @@ export const getAttachments = (numberOfAttachments: number): BulkCreateCommentRe
return {
type: CommentType.alert,
alertId: `test-id-${index + 1}`,
index: `test-index-${index + 1}`,
alertId: [`test-id-${index + 1}`],
index: [`test-index-${index + 1}`],
rule: {
id: `rule-test-id-${index + 1}`,
name: `Test ${index + 1}`,

View file

@ -15,6 +15,7 @@ import {
AttributesTypeAlerts,
CaseStatuses,
CommentRequestExternalReferenceSOType,
CommentRequestAlertType,
} from '@kbn/cases-plugin/common/api';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
@ -26,6 +27,7 @@ import {
getFilesAttachmentReq,
fileAttachmentMetadata,
fileMetadata,
postCommentAlertMultipleIdsReq,
} from '../../../../common/lib/mock';
import {
deleteAllCaseItems,
@ -39,6 +41,7 @@ import {
updateCase,
getCaseUserActions,
removeServerGeneratedPropertiesFromUserAction,
getAllComments,
} from '../../../../common/lib/api';
import {
createSignalsIndex,
@ -121,8 +124,8 @@ export default ({ getService }: FtrProviderContext): void => {
expect(comment).to.eql({
type: postCommentAlertReq.type,
alertId: postCommentAlertReq.alertId,
index: postCommentAlertReq.index,
alertId: [postCommentAlertReq.alertId],
index: [postCommentAlertReq.index],
rule: postCommentAlertReq.rule,
created_by: defaultUser,
pushed_at: null,
@ -937,6 +940,74 @@ export default ({ getService }: FtrProviderContext): void => {
}
});
describe('alert filtering', () => {
it('not create a new attachment if the alert is already attached to the case', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await createComment({
supertest,
caseId: postedCase.id,
params: postCommentAlertReq,
});
await createComment({
supertest,
caseId: postedCase.id,
params: postCommentAlertReq,
});
const attachments = await getAllComments({ supertest, caseId: postedCase.id });
expect(attachments.length).to.eql(1);
});
it('should not create a new attachment if the alerts are already attached to the case', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await createComment({
supertest,
caseId: postedCase.id,
params: postCommentAlertMultipleIdsReq,
});
await createComment({
supertest,
caseId: postedCase.id,
params: postCommentAlertMultipleIdsReq,
});
const attachments = await getAllComments({ supertest, caseId: postedCase.id });
expect(attachments.length).to.eql(1);
});
it('should create a new attachment without alerts attached to the case', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await createComment({
supertest,
caseId: postedCase.id,
params: postCommentAlertMultipleIdsReq,
});
await createComment({
supertest,
caseId: postedCase.id,
params: {
...postCommentAlertMultipleIdsReq,
alertId: ['test-id-1', 'test-id-2', 'test-id-3'],
index: ['test-index-1', 'test-index-2', 'test-index-3'],
},
});
const attachments = await getAllComments({ supertest, caseId: postedCase.id });
expect(attachments.length).to.eql(2);
const secondAttachment = attachments[1] as CommentRequestAlertType;
expect(secondAttachment.alertId).to.eql(['test-id-3']);
expect(secondAttachment.index).to.eql(['test-index-3']);
});
});
describe('rbac', () => {
afterEach(async () => {
await deleteAllCaseItems(es);

View file

@ -13,6 +13,7 @@ import {
BulkCreateCommentRequest,
CaseResponse,
CaseStatuses,
CommentRequestAlertType,
CommentRequestExternalReferenceSOType,
CommentType,
} from '@kbn/cases-plugin/common/api';
@ -27,6 +28,7 @@ import {
fileAttachmentMetadata,
postExternalReferenceSOReq,
fileMetadata,
postCommentAlertMultipleIdsReq,
} from '../../../../common/lib/mock';
import {
deleteAllCaseItems,
@ -40,6 +42,7 @@ import {
removeServerGeneratedPropertiesFromUserAction,
createAndUploadFile,
deleteAllFiles,
getAllComments,
} from '../../../../common/lib/api';
import {
createSignalsIndex,
@ -1216,6 +1219,180 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
describe('alert filtering', () => {
it('does not create a new attachment if the alert is already attached to the case', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [postCommentAlertReq],
expectedHttpCode: 200,
});
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [postCommentAlertReq],
expectedHttpCode: 200,
});
const attachments = await getAllComments({ supertest, caseId: postedCase.id });
expect(attachments.length).to.eql(1);
});
it('does not create a new attachment if the alert is already attached to the case on the same request', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [postCommentAlertReq, postCommentAlertReq],
expectedHttpCode: 200,
});
const attachments = await getAllComments({ supertest, caseId: postedCase.id });
expect(attachments.length).to.eql(1);
});
it('should not create a new attachment if the alerts are already attached to the case', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [postCommentAlertMultipleIdsReq],
expectedHttpCode: 200,
});
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [postCommentAlertMultipleIdsReq],
expectedHttpCode: 200,
});
const attachments = await getAllComments({ supertest, caseId: postedCase.id });
expect(attachments.length).to.eql(1);
});
it('should not create a new attachment if the alerts are already attached to the case on the same request', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [postCommentAlertMultipleIdsReq, postCommentAlertMultipleIdsReq],
expectedHttpCode: 200,
});
const attachments = await getAllComments({ supertest, caseId: postedCase.id });
expect(attachments.length).to.eql(1);
});
it('should create a new attachment without alerts attached to the case', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [postCommentAlertMultipleIdsReq],
expectedHttpCode: 200,
});
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [
{
...postCommentAlertMultipleIdsReq,
alertId: ['test-id-1', 'test-id-2', 'test-id-3'],
index: ['test-index-1', 'test-index-2', 'test-index-3'],
},
],
expectedHttpCode: 200,
});
const attachments = await getAllComments({ supertest, caseId: postedCase.id });
expect(attachments.length).to.eql(2);
const secondAttachment = attachments[1] as CommentRequestAlertType;
expect(secondAttachment.alertId).to.eql(['test-id-3']);
expect(secondAttachment.index).to.eql(['test-index-3']);
});
it('should create a new attachment without alerts attached to the case on the same request', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [
postCommentAlertMultipleIdsReq,
{
...postCommentAlertMultipleIdsReq,
alertId: ['test-id-1', 'test-id-2', 'test-id-3'],
index: ['test-index-1', 'test-index-2', 'test-index-3'],
},
],
expectedHttpCode: 200,
});
const attachments = await getAllComments({ supertest, caseId: postedCase.id });
expect(attachments.length).to.eql(2);
const secondAttachment = attachments[1] as CommentRequestAlertType;
expect(secondAttachment.alertId).to.eql(['test-id-3']);
expect(secondAttachment.index).to.eql(['test-index-3']);
});
it('does not remove user comments when filtering out duplicate alerts', async () => {
const postedCase = await createCase(supertest, postCaseReq);
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [postCommentAlertMultipleIdsReq],
expectedHttpCode: 200,
});
await bulkCreateAttachments({
supertest,
caseId: postedCase.id,
params: [
postCommentUserReq,
{
...postCommentAlertMultipleIdsReq,
alertId: ['test-id-1', 'test-id-2', 'test-id-3'],
index: ['test-index-1', 'test-index-2', 'test-index-3'],
},
postCommentUserReq,
],
expectedHttpCode: 200,
});
const attachments = await getAllComments({ supertest, caseId: postedCase.id });
expect(attachments.length).to.eql(4);
const firstAlert = attachments[0] as CommentRequestAlertType;
const firstUserComment = attachments[1] as CommentRequestAlertType;
const secondAlert = attachments[2] as CommentRequestAlertType;
const secondUserComment = attachments[3] as CommentRequestAlertType;
expect(firstUserComment.type).to.eql('user');
expect(secondUserComment.type).to.eql('user');
expect(firstAlert.type).to.eql('alert');
expect(secondAlert.type).to.eql('alert');
expect(firstAlert.alertId).to.eql(['test-id-1', 'test-id-2']);
expect(firstAlert.index).to.eql(['test-index', 'test-index-2']);
expect(secondAlert.alertId).to.eql(['test-id-3']);
expect(secondAlert.index).to.eql(['test-index-3']);
});
});
describe('rbac', () => {
afterEach(async () => {
await deleteAllCaseItems(es);