[Cases] Remove case info from alerts when deleting an alert attachment (#154024)

## Summary

Users can remove alerts from a case by deleting the whole alert
attachment. This PR removes the case id from the alerts when deleting an
attachment of type alert. It does not remove the case info from all
alerts attached to a case when deleting a case. It also fixes a bug
where the success toaster will not show when deleting an attachment.

Related: https://github.com/elastic/kibana/issues/146864,
https://github.com/elastic/kibana/issues/140800

## Testing

1. Create a case and attach some alerts to the case.
2. Verify that the alerts table (in security or in o11y) shows the case
the alert is attached to. You can enable the cases column by pressing
"Fields", searching for "Cases", and then selecting the field.
3. Go to the case and find the alerts' user activity.
4. Press the `...` and press "Remove alerts(s)"
5. Go back to the alert table and verify that the case is not shown in
the Cases column for each alert.

Please check that when you remove alert(s), attachments (ml, etc), and
comments you get a success toaster with the correct text.

### 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-01 17:51:06 +03:00 committed by GitHub
parent 41fb8f3b50
commit 3a6f7211b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1041 additions and 53 deletions

View file

@ -0,0 +1,59 @@
/*
* 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 { CommentAttributes } from '../api';
import { CommentType } from '../api';
import {
isCommentRequestTypeExternalReference,
isCommentRequestTypePersistableState,
} from './attachments';
describe('attachments utils', () => {
describe('isCommentRequestTypeExternalReference', () => {
const externalReferenceAttachment = {
type: CommentType.externalReference as const,
} as CommentAttributes;
const commentTypeWithoutAlert = Object.values(CommentType).filter(
(type) => type !== CommentType.externalReference
);
it('returns false for type: externalReference', () => {
expect(isCommentRequestTypeExternalReference(externalReferenceAttachment)).toBe(true);
});
it.each(commentTypeWithoutAlert)('returns false for type: %s', (type) => {
const attachment = {
type,
} as CommentAttributes;
expect(isCommentRequestTypeExternalReference(attachment)).toBe(false);
});
});
describe('isCommentRequestTypePersistableState', () => {
const persistableStateAttachment = {
type: CommentType.persistableState as const,
} as CommentAttributes;
const commentTypeWithoutAlert = Object.values(CommentType).filter(
(type) => type !== CommentType.persistableState
);
it('returns false for type: persistableState', () => {
expect(isCommentRequestTypePersistableState(persistableStateAttachment)).toBe(true);
});
it.each(commentTypeWithoutAlert)('returns false for type: %s', (type) => {
const attachment = {
type,
} as CommentAttributes;
expect(isCommentRequestTypePersistableState(attachment)).toBe(false);
});
});
});

View file

@ -21,6 +21,7 @@ import { ShowAlertTableLink } from './show_alert_table_link';
import { HoverableUserWithAvatarResolver } from '../../user_profiles/hoverable_user_with_avatar_resolver';
import { UserActionContentToolbar } from '../content_toolbar';
import { AlertPropertyActions } from '../property_actions/alert_property_actions';
import { DELETE_ALERTS_SUCCESS_TITLE } from './translations';
type BuilderArgs = Pick<
UserActionBuilderArgs,
@ -88,7 +89,7 @@ const getSingleAlertUserAction = ({
/>
</EuiFlexItem>
<AlertPropertyActions
onDelete={() => handleDeleteComment(comment.id)}
onDelete={() => handleDeleteComment(comment.id, DELETE_ALERTS_SUCCESS_TITLE(1))}
isLoading={loadingCommentIds.includes(comment.id)}
totalAlerts={1}
/>
@ -142,7 +143,9 @@ const getMultipleAlertsUserAction = ({
<ShowAlertTableLink />
</EuiFlexItem>
<AlertPropertyActions
onDelete={() => handleDeleteComment(comment.id)}
onDelete={() =>
handleDeleteComment(comment.id, DELETE_ALERTS_SUCCESS_TITLE(totalAlerts))
}
isLoading={loadingCommentIds.includes(comment.id)}
totalAlerts={totalAlerts}
/>

View file

@ -201,7 +201,10 @@ describe('createCommentUserActionBuilder', () => {
await deleteAttachment(result, 'trash', 'Delete');
await waitFor(() => {
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith('basic-comment-id');
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith(
'basic-comment-id',
'Deleted comment'
);
});
});
@ -319,7 +322,10 @@ describe('createCommentUserActionBuilder', () => {
await deleteAttachment(res, 'minusInCircle', 'Remove');
await waitFor(() => {
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith('alert-comment-id');
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith(
'alert-comment-id',
'Deleted one alert'
);
});
});
@ -414,7 +420,10 @@ describe('createCommentUserActionBuilder', () => {
await deleteAttachment(res, 'minusInCircle', 'Remove');
await waitFor(() => {
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith('alert-comment-id');
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith(
'alert-comment-id',
'Deleted 2 alerts'
);
});
});
@ -590,7 +599,8 @@ describe('createCommentUserActionBuilder', () => {
await waitFor(() => {
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith(
'external-reference-comment-id'
'external-reference-comment-id',
'Deleted attachment'
);
});
});
@ -761,7 +771,8 @@ describe('createCommentUserActionBuilder', () => {
await waitFor(() => {
expect(builderArgs.handleDeleteComment).toHaveBeenCalledWith(
'persistable-state-comment-id'
'persistable-state-comment-id',
'Deleted attachment'
);
});
});

View file

@ -21,7 +21,11 @@ import type { CommentResponse } from '../../../../common/api';
import type { UserActionBuilder, UserActionBuilderArgs } from '../types';
import { UserActionTimestamp } from '../timestamp';
import type { SnakeToCamelCase } from '../../../../common/types';
import { ATTACHMENT_NOT_REGISTERED_ERROR, DEFAULT_EVENT_ATTACHMENT_TITLE } from './translations';
import {
ATTACHMENT_NOT_REGISTERED_ERROR,
DEFAULT_EVENT_ATTACHMENT_TITLE,
DELETE_REGISTERED_ATTACHMENT,
} from './translations';
import { UserActionContentToolbar } from '../content_toolbar';
import { HoverableUserWithAvatarResolver } from '../../user_profiles/hoverable_user_with_avatar_resolver';
import { RegisteredAttachmentsPropertyActions } from '../property_actions/registered_attachments_property_actions';
@ -127,7 +131,7 @@ export const createRegisteredAttachmentUserActionBuilder = <
{attachmentViewObject.actions}
<RegisteredAttachmentsPropertyActions
isLoading={isLoading}
onDelete={() => handleDeleteComment(comment.id)}
onDelete={() => handleDeleteComment(comment.id, DELETE_REGISTERED_ATTACHMENT)}
/>
</UserActionContentToolbar>
),

View file

@ -39,3 +39,24 @@ export const UNSAVED_DRAFT_COMMENT = i18n.translate(
defaultMessage: 'You have unsaved edits for this comment',
}
);
export const DELETE_ALERTS_SUCCESS_TITLE = (totalAlerts: number) =>
i18n.translate('xpack.cases.userActions.attachments.alerts.successToasterTitle', {
defaultMessage:
'Deleted {totalAlerts, plural, =1 {one} other {{totalAlerts}}} {totalAlerts, plural, =1 {alert} other {alerts}}',
values: { totalAlerts },
});
export const DELETE_COMMENT_SUCCESS_TITLE = i18n.translate(
'xpack.cases.userActions.attachments.comment.successToasterTitle',
{
defaultMessage: 'Deleted comment',
}
);
export const DELETE_REGISTERED_ATTACHMENT = i18n.translate(
'xpack.cases.userActions.attachments.registeredAttachment.successToasterTitle',
{
defaultMessage: 'Deleted attachment',
}
);

View file

@ -115,7 +115,7 @@ export const createUserAttachmentUserActionBuilder = ({
isLoading={isLoading}
commentContent={comment.comment}
onEdit={() => handleManageMarkdownEditId(comment.id)}
onDelete={() => handleDeleteComment(comment.id)}
onDelete={() => handleDeleteComment(comment.id, i18n.DELETE_COMMENT_SUCCESS_TITLE)}
onQuote={() => handleManageQuote(comment.comment)}
/>
</UserActionContentToolbar>

View file

@ -69,7 +69,7 @@ export interface UserActionBuilderArgs {
handleOutlineComment: (id: string) => void;
handleManageMarkdownEditId: (id: string) => void;
handleSaveComment: ({ id, version }: { id: string; version: string }, content: string) => void;
handleDeleteComment: (id: string) => void;
handleDeleteComment: (id: string, successToasterTitle: string) => void;
handleManageQuote: (quote: string) => void;
onShowAlertDetails: (alertId: string, index: string) => void;
getRuleDetailsHref?: RuleDetailsNavigation['href'];

View file

@ -76,8 +76,8 @@ export const useUserActionsHandler = (): UseUserActionsHandler => {
);
const handleDeleteComment = useCallback(
(id: string) => {
deleteComment({ caseId, commentId: id });
(id: string, successToasterTitle: string) => {
deleteComment({ caseId, commentId: id, successToasterTitle });
},
[caseId, deleteComment]
);

View file

@ -5,23 +5,21 @@
* 2.0.
*/
import React from 'react';
import { act, renderHook } from '@testing-library/react-hooks';
import type { UseDeleteComment } from './use_delete_comment';
import { useDeleteComment } from './use_delete_comment';
import * as api from './api';
import { basicCaseId } from './mock';
import { TestProviders } from '../common/mock';
import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page';
import { useToasts } from '../common/lib/kibana';
import type { AppMockRenderer } from '../common/mock';
import { createAppMockRenderer } from '../common/mock';
jest.mock('../common/lib/kibana');
jest.mock('./api');
jest.mock('../components/case_view/use_on_refresh_case_view_page');
const commentId = 'ab124';
const wrapper: React.FC<string> = ({ children }) => <TestProviders>{children}</TestProviders>;
const successToasterTitle = 'Deleted';
describe('useDeleteComment', () => {
const addSuccess = jest.fn();
@ -29,13 +27,16 @@ describe('useDeleteComment', () => {
(useToasts as jest.Mock).mockReturnValue({ addSuccess, addError });
let appMockRender: AppMockRenderer;
beforeEach(() => {
appMockRender = createAppMockRenderer();
jest.clearAllMocks();
});
it('init', async () => {
const { result } = renderHook<string, UseDeleteComment>(() => useDeleteComment(), {
wrapper,
const { result } = renderHook(() => useDeleteComment(), {
wrapper: appMockRender.AppWrapper,
});
expect(result.current).toBeTruthy();
@ -44,17 +45,15 @@ describe('useDeleteComment', () => {
it('calls deleteComment with correct arguments - case', async () => {
const spyOnDeleteComment = jest.spyOn(api, 'deleteComment');
const { waitForNextUpdate, result } = renderHook<string, UseDeleteComment>(
() => useDeleteComment(),
{
wrapper,
}
);
const { waitForNextUpdate, result } = renderHook(() => useDeleteComment(), {
wrapper: appMockRender.AppWrapper,
});
act(() => {
result.current.mutate({
caseId: basicCaseId,
commentId,
successToasterTitle,
});
});
@ -68,16 +67,14 @@ describe('useDeleteComment', () => {
});
it('refreshes the case page view after delete', async () => {
const { waitForNextUpdate, result } = renderHook<string, UseDeleteComment>(
() => useDeleteComment(),
{
wrapper,
}
);
const { waitForNextUpdate, result } = renderHook(() => useDeleteComment(), {
wrapper: appMockRender.AppWrapper,
});
result.current.mutate({
caseId: basicCaseId,
commentId,
successToasterTitle,
});
await waitForNextUpdate();
@ -85,20 +82,39 @@ describe('useDeleteComment', () => {
expect(useRefreshCaseViewPage()).toBeCalled();
});
it('shows a success toaster correctly', async () => {
const { waitForNextUpdate, result } = renderHook(() => useDeleteComment(), {
wrapper: appMockRender.AppWrapper,
});
act(() => {
result.current.mutate({
caseId: basicCaseId,
commentId,
successToasterTitle,
});
});
await waitForNextUpdate();
expect(addSuccess).toHaveBeenCalledWith({
title: 'Deleted',
className: 'eui-textBreakWord',
});
});
it('sets isError when fails to delete a case', async () => {
const spyOnDeleteComment = jest.spyOn(api, 'deleteComment');
spyOnDeleteComment.mockRejectedValue(new Error('Not possible :O'));
spyOnDeleteComment.mockRejectedValue(new Error('Error'));
const { waitForNextUpdate, result } = renderHook<string, UseDeleteComment>(
() => useDeleteComment(),
{
wrapper,
}
);
const { waitForNextUpdate, result } = renderHook(() => useDeleteComment(), {
wrapper: appMockRender.AppWrapper,
});
result.current.mutate({
caseId: basicCaseId,
commentId,
successToasterTitle,
});
await waitForNextUpdate();

View file

@ -16,10 +16,11 @@ import * as i18n from './translations';
interface MutationArgs {
caseId: string;
commentId: string;
successToasterTitle: string;
}
export const useDeleteComment = () => {
const { showErrorToast } = useCasesToast();
const { showErrorToast, showSuccessToast } = useCasesToast();
const refreshCaseViewPage = useRefreshCaseViewPage();
return useMutation(
@ -29,7 +30,8 @@ export const useDeleteComment = () => {
},
{
mutationKey: casesMutationsKeys.deleteComment,
onSuccess: () => {
onSuccess: (_, { successToasterTitle }) => {
showSuccessToast(successToasterTitle);
refreshCaseViewPage();
},
onError: (error: ServerError) => {

View file

@ -42,3 +42,8 @@ export interface UpdateAlertCasesRequest {
alerts: AlertInfo[];
caseIds: string[];
}
export interface RemoveAlertsFromCaseRequest {
alerts: AlertInfo[];
caseId: string;
}

View file

@ -0,0 +1,46 @@
/*
* 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 { mockCaseComments } from '../../mocks';
import { createCasesClientMockArgs } from '../mocks';
import { deleteComment } from './delete';
describe('deleteComment', () => {
const clientArgs = createCasesClientMockArgs();
beforeEach(() => {
jest.clearAllMocks();
});
describe('Alerts', () => {
const commentSO = mockCaseComments[0];
const alertsSO = mockCaseComments[3];
clientArgs.services.attachmentService.getter.get.mockResolvedValue(alertsSO);
it('delete alerts correctly', async () => {
await deleteComment({ caseID: 'mock-id-4', attachmentID: 'mock-comment-4' }, clientArgs);
expect(clientArgs.services.alertsService.ensureAlertsAuthorized).toHaveBeenCalledWith({
alerts: [{ id: 'test-id', index: 'test-index' }],
});
expect(clientArgs.services.alertsService.removeCaseIdFromAlerts).toHaveBeenCalledWith({
alerts: [{ id: 'test-id', index: 'test-index' }],
caseId: 'mock-id-4',
});
});
it('does not call the alert service when the attachment is not an alert', async () => {
clientArgs.services.attachmentService.getter.get.mockResolvedValue(commentSO);
await deleteComment({ caseID: 'mock-id-1', attachmentID: 'mock-comment-1' }, clientArgs);
expect(clientArgs.services.alertsService.ensureAlertsAuthorized).not.toHaveBeenCalledWith();
expect(clientArgs.services.alertsService.removeCaseIdFromAlerts).not.toHaveBeenCalledWith();
});
});
});

View file

@ -11,6 +11,7 @@ import pMap from 'p-map';
import type { SavedObject } from '@kbn/core/server';
import type { CommentAttributes } from '../../../common/api';
import { Actions, ActionTypes } from '../../../common/api';
import { getAlertInfoFromComments, isCommentRequestTypeAlert } from '../../common/utils';
import { CASE_SAVED_OBJECT, MAX_CONCURRENT_SEARCHES } from '../../../common/constants';
import type { CasesClientArgs } from '../types';
import { createCaseError } from '../../common/error';
@ -90,29 +91,29 @@ export async function deleteComment(
) {
const {
user,
services: { attachmentService, userActionService },
services: { attachmentService, userActionService, alertsService },
logger,
authorization,
} = clientArgs;
try {
const myComment = await attachmentService.getter.get({
const attachment = await attachmentService.getter.get({
attachmentId: attachmentID,
});
if (myComment == null) {
if (attachment == null) {
throw Boom.notFound(`This comment ${attachmentID} does not exist anymore.`);
}
await authorization.ensureAuthorized({
entities: [{ owner: myComment.attributes.owner, id: myComment.id }],
entities: [{ owner: attachment.attributes.owner, id: attachment.id }],
operation: Operations.deleteComment,
});
const type = CASE_SAVED_OBJECT;
const id = caseID;
const caseRef = myComment.references.find((c) => c.type === type);
const caseRef = attachment.references.find((c) => c.type === type);
if (caseRef == null || (caseRef != null && caseRef.id !== id)) {
throw Boom.notFound(`This comment ${attachmentID} does not exist in ${id}.`);
}
@ -127,10 +128,12 @@ export async function deleteComment(
action: Actions.delete,
caseId: id,
attachmentId: attachmentID,
payload: { attachment: { ...myComment.attributes } },
payload: { attachment: { ...attachment.attributes } },
user,
owner: myComment.attributes.owner,
owner: attachment.attributes.owner,
});
await handleAlerts({ alertsService, attachment: attachment.attributes, caseId: id });
} catch (error) {
throw createCaseError({
message: `Failed to delete comment: ${caseID} comment id: ${attachmentID}: ${error}`,
@ -139,3 +142,19 @@ export async function deleteComment(
});
}
}
interface HandleAlertsArgs {
alertsService: CasesClientArgs['services']['alertsService'];
attachment: CommentAttributes;
caseId: string;
}
const handleAlerts = async ({ alertsService, attachment, caseId }: HandleAlertsArgs) => {
if (!isCommentRequestTypeAlert(attachment)) {
return;
}
const alerts = getAlertInfoFromComments([attachment]);
await alertsService.ensureAlertsAuthorized({ alerts });
await alertsService.removeCaseIdFromAlerts({ alerts, caseId });
};

View file

@ -381,4 +381,47 @@ describe('updateAlertsStatus', () => {
expect(alertsClient.bulkUpdateCases).not.toHaveBeenCalled();
});
});
describe('removeCaseIdFromAlerts', () => {
const alerts = [
{
id: 'c3869d546717e8c581add9cbf7d24578f34cd3e72cbc8d8b8e9a9330a899f70f',
index: '.internal.alerts-security.alerts-default-000001',
},
];
const caseId = 'test-case';
it('update case info', async () => {
await alertService.removeCaseIdFromAlerts({ alerts, caseId });
expect(alertsClient.removeCaseIdFromAlerts).toBeCalledWith({ alerts, caseId });
});
it('filters out alerts with empty id', async () => {
await alertService.removeCaseIdFromAlerts({
alerts: [{ id: '', index: 'test-index' }, ...alerts],
caseId,
});
expect(alertsClient.removeCaseIdFromAlerts).toBeCalledWith({ alerts, caseId });
});
it('filters out alerts with empty index', async () => {
await alertService.removeCaseIdFromAlerts({
alerts: [{ id: 'test-id', index: '' }, ...alerts],
caseId,
});
expect(alertsClient.removeCaseIdFromAlerts).toBeCalledWith({ alerts, caseId });
});
it('does not call the alerts client with no alerts', async () => {
await alertService.removeCaseIdFromAlerts({
alerts: [{ id: '', index: 'test-index' }],
caseId,
});
expect(alertsClient.removeCaseIdFromAlerts).not.toHaveBeenCalled();
});
});
});

View file

@ -18,7 +18,11 @@ import { CaseStatuses } from '../../../common/api';
import { MAX_ALERTS_PER_CASE, MAX_CONCURRENT_SEARCHES } from '../../../common/constants';
import { createCaseError } from '../../common/error';
import type { AlertInfo } from '../../common/types';
import type { UpdateAlertCasesRequest, UpdateAlertStatusRequest } from '../../client/alerts/types';
import type {
RemoveAlertsFromCaseRequest,
UpdateAlertCasesRequest,
UpdateAlertStatusRequest,
} from '../../client/alerts/types';
import type { AggregationBuilder, AggregationResponse } from '../../client/metrics/types';
export class AlertService {
@ -230,6 +234,30 @@ export class AlertService {
}
}
public async removeCaseIdFromAlerts({
alerts,
caseId,
}: RemoveAlertsFromCaseRequest): Promise<void> {
try {
const nonEmptyAlerts = this.getNonEmptyAlerts(alerts);
if (nonEmptyAlerts.length <= 0) {
return;
}
await this.alertsClient.removeCaseIdFromAlerts({
alerts: nonEmptyAlerts,
caseId,
});
} catch (error) {
throw createCaseError({
message: `Failed to remove case ${caseId} from alerts: ${error}`,
error,
logger: this.logger,
});
}
}
public async ensureAlertsAuthorized({ alerts }: { alerts: AlertInfo[] }): Promise<void> {
try {
const nonEmptyAlerts = this.getNonEmptyAlerts(alerts);

View file

@ -139,6 +139,7 @@ export const createAlertServiceMock = (): AlertServiceMock => {
executeAggregations: jest.fn(),
bulkUpdateCases: jest.fn(),
ensureAlertsAuthorized: jest.fn(),
removeCaseIdFromAlerts: jest.fn(),
};
// the cast here is required because jest.Mocked tries to include private members and would throw an error

View file

@ -23,6 +23,7 @@ const createAlertsClientMock = () => {
getBrowserFields: jest.fn(),
getAlertSummary: jest.fn(),
ensureAllAlertsAuthorizedRead: jest.fn(),
removeCaseIdFromAlerts: jest.fn(),
};
return mocked;
};

View file

@ -105,6 +105,11 @@ export interface BulkUpdateCasesOptions {
caseIds: string[];
}
export interface RemoveAlertsFromCaseOptions {
alerts: MgetAndAuditAlert[];
caseId: string;
}
interface GetAlertParams {
id: string;
index?: string;
@ -846,6 +851,51 @@ export class AlertsClient {
});
}
public async removeCaseIdFromAlerts({ caseId, alerts }: RemoveAlertsFromCaseOptions) {
try {
if (alerts.length === 0) {
return;
}
const mgetRes = await this.ensureAllAlertsAuthorized({
alerts,
operation: ReadOperations.Get,
});
const painlessScript = `if (ctx._source['${ALERT_CASE_IDS}'] != null) {
for (int i=0; i < ctx._source['${ALERT_CASE_IDS}'].length; i++) {
if (ctx._source['${ALERT_CASE_IDS}'][i].equals('${caseId}')) {
ctx._source['${ALERT_CASE_IDS}'].remove(i);
}
}
}`;
const bulkUpdateRequest = [];
for (const doc of mgetRes.docs) {
bulkUpdateRequest.push(
{
update: {
_index: doc._index,
_id: doc._id,
},
},
{
script: { source: painlessScript, lang: 'painless' },
}
);
}
await this.esClient.bulk({
refresh: 'wait_for',
body: bulkUpdateRequest,
});
} catch (error) {
this.logger.error(`Error to remove case ${caseId} from alerts: ${error}`);
throw error;
}
}
public async find<Params extends RuleTypeParams = never>({
aggs,
featureIds,

View file

@ -0,0 +1,304 @@
/*
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import {
ALERT_CASE_IDS,
ALERT_RULE_CONSUMER,
ALERT_RULE_TYPE_ID,
MAX_CASES_PER_ALERT,
} from '@kbn/rule-data-utils';
import { AlertsClient, ConstructorOptions } from '../alerts_client';
import { ruleDataServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock';
describe('bulkUpdateCases', () => {
const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
const auditLogger = auditLoggerMock.create();
const caseIds = ['test-case'];
const alerts = [
{
id: 'alert-id',
index: 'alert-index',
},
];
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
logger: loggingSystemMock.create().get(),
authorization: alertingAuthMock,
esClient: esClientMock,
auditLogger,
ruleDataService: ruleDataServiceMock.create(),
};
beforeEach(() => {
jest.clearAllMocks();
esClientMock.mget.mockResponse({
docs: [
{
found: true,
_id: 'alert-id',
_index: 'alert-index',
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_CASE_IDS]: caseIds,
},
},
],
});
});
it('bulks update the alerts with case info correctly', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.bulkUpdateCases({ caseIds, alerts });
expect(esClientMock.mget).toHaveBeenCalledWith({
docs: [{ _id: 'alert-id', _index: 'alert-index' }],
});
expect(esClientMock.bulk.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"body": Array [
Object {
"update": Object {
"_id": "alert-id",
"_index": "alert-index",
},
},
Object {
"doc": Object {
"kibana.alert.case_ids": Array [
"test-case",
],
},
},
],
"refresh": "wait_for",
}
`);
});
it('bulks update correctly with multiple cases and alerts', async () => {
const multipleAlerts = [
...alerts,
{
id: 'alert-id-2',
index: 'alert-index-2',
},
];
const multipleCases = [...caseIds, 'another-case'];
esClientMock.mget.mockResponse({
docs: multipleAlerts.map((alert) => ({
found: true,
_id: alert.id,
_index: alert.index,
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_CASE_IDS]: multipleCases,
},
})),
});
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.bulkUpdateCases({
caseIds: multipleCases,
alerts: multipleAlerts,
});
expect(esClientMock.mget).toHaveBeenCalledWith({
docs: [
{ _id: 'alert-id', _index: 'alert-index' },
{ _id: 'alert-id-2', _index: 'alert-index-2' },
],
});
expect(esClientMock.bulk.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"body": Array [
Object {
"update": Object {
"_id": "alert-id",
"_index": "alert-index",
},
},
Object {
"doc": Object {
"kibana.alert.case_ids": Array [
"test-case",
"another-case",
],
},
},
Object {
"update": Object {
"_id": "alert-id-2",
"_index": "alert-index-2",
},
},
Object {
"doc": Object {
"kibana.alert.case_ids": Array [
"test-case",
"another-case",
],
},
},
],
"refresh": "wait_for",
}
`);
});
it('removes duplicated cases', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.bulkUpdateCases({
caseIds: [...caseIds, ...caseIds],
alerts,
});
expect(esClientMock.bulk.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"body": Array [
Object {
"update": Object {
"_id": "alert-id",
"_index": "alert-index",
},
},
Object {
"doc": Object {
"kibana.alert.case_ids": Array [
"test-case",
],
},
},
],
"refresh": "wait_for",
}
`);
});
it('calls ensureAllAlertsAuthorized correctly', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.bulkUpdateCases({ caseIds, alerts });
expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({
consumer: 'apm',
entity: 'alert',
operation: 'get',
ruleTypeId: 'apm.error_rate',
});
});
it(`throws an error when adding a case to an alert with ${MAX_CASES_PER_ALERT} cases`, async () => {
esClientMock.mget.mockResponse({
docs: [
{
found: true,
_id: 'alert-id',
_index: 'alert-index',
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_CASE_IDS]: Array.from(Array(10).keys()),
},
},
],
});
const alertsClient = new AlertsClient(alertsClientParams);
await expect(
alertsClient.bulkUpdateCases({ caseIds, alerts })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"You cannot attach more than 10 cases to an alert"`
);
});
it(`throws an error when adding more than ${MAX_CASES_PER_ALERT} cases to an alert`, async () => {
esClientMock.mget.mockResponse({
docs: [
{
found: true,
_id: 'alert-id',
_index: 'alert-index',
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_CASE_IDS]: [],
},
},
],
});
const alertsClient = new AlertsClient(alertsClientParams);
const multipleCases = Array.from(Array(11).values());
await expect(
alertsClient.bulkUpdateCases({ caseIds: multipleCases, alerts })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"You cannot attach more than 10 cases to an alert"`
);
});
it(`throws an error when the sum of cases extends the limit of ${MAX_CASES_PER_ALERT} cases per alert`, async () => {
esClientMock.mget.mockResponse({
docs: [
{
found: true,
_id: 'alert-id',
_index: 'alert-index',
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_CASE_IDS]: Array.from(Array(5).keys()),
},
},
],
});
const alertsClient = new AlertsClient(alertsClientParams);
const multipleCases = Array.from(Array(6).values());
await expect(
alertsClient.bulkUpdateCases({ caseIds: multipleCases, alerts })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"You cannot attach more than 10 cases to an alert"`
);
});
it('throws an error when no alerts are provided', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await expect(
alertsClient.bulkUpdateCases({ caseIds, alerts: [] })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"You need to define at least one alert to update case ids"`
);
});
it(`throws an error when the provided case ids are more than ${MAX_CASES_PER_ALERT}`, async () => {
const alertsClient = new AlertsClient(alertsClientParams);
const multipleCases = Array.from(Array(11).values());
await expect(
alertsClient.bulkUpdateCases({ caseIds: multipleCases, alerts })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"You cannot attach more than 10 cases to an alert"`
);
});
});

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { alertingAuthorizationMock } from '@kbn/alerting-plugin/server/authorization/alerting_authorization.mock';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { ALERT_CASE_IDS, ALERT_RULE_CONSUMER, ALERT_RULE_TYPE_ID } from '@kbn/rule-data-utils';
import { AlertsClient, ConstructorOptions } from '../alerts_client';
import { ruleDataServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock';
describe('removeCaseIdFromAlerts', () => {
const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
const auditLogger = auditLoggerMock.create();
const caseId = 'test-case';
const alerts = [
{
id: 'alert-id',
index: 'alert-index',
},
];
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
logger: loggingSystemMock.create().get(),
authorization: alertingAuthMock,
esClient: esClientMock,
auditLogger,
ruleDataService: ruleDataServiceMock.create(),
};
beforeEach(() => {
jest.clearAllMocks();
esClientMock.mget.mockResponseOnce({
docs: [
{
found: true,
_id: 'alert-id',
_index: 'alert-index',
_source: {
[ALERT_RULE_TYPE_ID]: 'apm.error_rate',
[ALERT_RULE_CONSUMER]: 'apm',
[ALERT_CASE_IDS]: [caseId],
},
},
],
});
});
it('removes alerts from a case', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.removeCaseIdFromAlerts({ caseId, alerts });
expect(esClientMock.mget).toHaveBeenCalledWith({
docs: [{ _id: 'alert-id', _index: 'alert-index' }],
});
expect(esClientMock.bulk.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"body": Array [
Object {
"update": Object {
"_id": "alert-id",
"_index": "alert-index",
},
},
Object {
"script": Object {
"lang": "painless",
"source": "if (ctx._source['kibana.alert.case_ids'] != null) {
for (int i=0; i < ctx._source['kibana.alert.case_ids'].length; i++) {
if (ctx._source['kibana.alert.case_ids'][i].equals('test-case')) {
ctx._source['kibana.alert.case_ids'].remove(i);
}
}
}",
},
},
],
"refresh": "wait_for",
}
`);
});
it('calls ensureAllAlertsAuthorized correctly', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.removeCaseIdFromAlerts({ caseId, alerts });
expect(alertingAuthMock.ensureAuthorized).toHaveBeenCalledWith({
consumer: 'apm',
entity: 'alert',
operation: 'get',
ruleTypeId: 'apm.error_rate',
});
});
it('does not do any calls if there are no alerts', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.removeCaseIdFromAlerts({ caseId, alerts: [] });
expect(alertingAuthMock.ensureAuthorized).not.toHaveBeenCalled();
expect(esClientMock.bulk).not.toHaveBeenCalled();
});
});

View file

@ -6,7 +6,20 @@
*/
import expect from '@kbn/expect';
import { CommentType } from '@kbn/cases-plugin/common';
import { ALERT_CASE_IDS } from '@kbn/rule-data-utils';
import {
createSecuritySolutionAlerts,
getAlertById,
getSecuritySolutionAlerts,
} from '../../../../common/lib/alerts';
import {
createSignalsIndex,
deleteSignalsIndex,
deleteAllRules,
} from '../../../../../detection_engine_api_integration/utils';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { User } from '../../../../common/lib/authentication/types';
import { getPostCaseRequest, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock';
import {
@ -25,9 +38,13 @@ import {
noKibanaPrivileges,
obsOnly,
obsOnlyRead,
obsOnlyReadAlerts,
obsSec,
obsSecRead,
secOnly,
secOnlyRead,
secOnlyReadAlerts,
secSolutionOnlyReadNoIndexAlerts,
superUser,
} from '../../../../common/lib/authentication/users';
@ -35,6 +52,9 @@ import {
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
const esArchiver = getService('esArchiver');
const log = getService('log');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('delete_comment', () => {
afterEach(async () => {
@ -91,9 +111,255 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
describe('rbac', () => {
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('alerts', () => {
type Alerts = Array<{ _id: string; _index: string }>;
const createCaseAttachAlertAndDeleteAlert = async ({
totalCases,
indexOfCaseToDelete,
owner,
expectedHttpCode = 204,
deleteCommentAuth = { user: superUser, space: 'space1' },
alerts,
getAlerts,
}: {
totalCases: number;
indexOfCaseToDelete: number;
owner: string;
expectedHttpCode?: number;
deleteCommentAuth?: { user: User; space: string | null };
alerts: Alerts;
getAlerts: (alerts: Alerts) => Promise<Array<Record<string, unknown>>>;
}) => {
const cases = await Promise.all(
[...Array(totalCases).keys()].map((index) =>
createCase(
supertestWithoutAuth,
{
...postCaseReq,
owner,
settings: { syncAlerts: false },
},
200,
{ user: superUser, space: 'space1' }
)
)
);
const updatedCases = [];
for (const theCase of cases) {
const updatedCase = await createComment({
supertest: supertestWithoutAuth,
caseId: theCase.id,
params: {
alertId: alerts.map((alert) => alert._id),
index: alerts.map((alert) => alert._index),
rule: {
id: 'id',
name: 'name',
},
owner,
type: CommentType.alert,
},
auth: { user: superUser, space: 'space1' },
});
updatedCases.push(updatedCase);
}
const caseIds = updatedCases.map((theCase) => theCase.id);
const updatedAlerts = await getAlerts(alerts);
for (const alert of updatedAlerts) {
expect(alert[ALERT_CASE_IDS]).eql(caseIds);
}
const caseToDelete = updatedCases[indexOfCaseToDelete];
const commentId = caseToDelete.comments![0].id;
await deleteComment({
supertest: supertestWithoutAuth,
caseId: caseToDelete.id,
commentId,
expectedHttpCode,
auth: deleteCommentAuth,
});
const alertAfterDeletion = await getAlerts(alerts);
const caseIdsWithoutRemovedCase =
expectedHttpCode === 204
? updatedCases
.filter((theCase) => theCase.id !== caseToDelete.id)
.map((theCase) => theCase.id)
: updatedCases.map((theCase) => theCase.id);
for (const alert of alertAfterDeletion) {
expect(alert[ALERT_CASE_IDS]).eql(caseIdsWithoutRemovedCase);
}
};
describe('security_solution', () => {
let alerts: Alerts = [];
const getAlerts = async (_alerts: Alerts) => {
await es.indices.refresh({ index: _alerts.map((alert) => alert._index) });
const updatedAlerts = await getSecuritySolutionAlerts(
supertest,
alerts.map((alert) => alert._id)
);
return updatedAlerts.hits.hits.map((alert) => ({ ...alert._source }));
};
beforeEach(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
await createSignalsIndex(supertest, log);
const signals = await createSecuritySolutionAlerts(supertest, log);
alerts = [signals.hits.hits[0], signals.hits.hits[1]];
});
afterEach(async () => {
await deleteSignalsIndex(supertest, log);
await deleteAllRules(supertest, log);
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
});
it('removes a case from the alert schema when deleting an alert attachment', async () => {
await createCaseAttachAlertAndDeleteAlert({
totalCases: 1,
indexOfCaseToDelete: 0,
owner: 'securitySolutionFixture',
alerts,
getAlerts,
});
});
it('should remove only one case', async () => {
await createCaseAttachAlertAndDeleteAlert({
totalCases: 3,
indexOfCaseToDelete: 1,
owner: 'securitySolutionFixture',
alerts,
getAlerts,
});
});
it('should delete case ID from the alert schema when the user has write access to the indices and only read access to the siem solution', async () => {
await createCaseAttachAlertAndDeleteAlert({
totalCases: 1,
indexOfCaseToDelete: 0,
owner: 'securitySolutionFixture',
alerts,
getAlerts,
expectedHttpCode: 204,
deleteCommentAuth: { user: secOnlyReadAlerts, space: 'space1' },
});
});
it('should NOT delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
await createCaseAttachAlertAndDeleteAlert({
totalCases: 1,
indexOfCaseToDelete: 0,
owner: 'securitySolutionFixture',
alerts,
getAlerts,
expectedHttpCode: 403,
deleteCommentAuth: { user: obsSec, space: 'space1' },
});
});
it('should delete the case ID from the alert schema when the user has read access to the kibana feature but no read access to the ES index', async () => {
await createCaseAttachAlertAndDeleteAlert({
totalCases: 1,
indexOfCaseToDelete: 0,
owner: 'securitySolutionFixture',
alerts,
getAlerts,
expectedHttpCode: 204,
deleteCommentAuth: { user: secSolutionOnlyReadNoIndexAlerts, space: 'space1' },
});
});
});
describe('observability', () => {
const alerts = [
{ _id: 'NoxgpHkBqbdrfX07MqXV', _index: '.alerts-observability.apm.alerts' },
{ _id: 'space1alert', _index: '.alerts-observability.apm.alerts' },
];
const getAlerts = async (_alerts: Alerts) => {
const updatedAlerts = await Promise.all(
_alerts.map((alert) =>
getAlertById({
supertest: supertestWithoutAuth,
id: alert._id,
index: alert._index,
auth: { user: superUser, space: 'space1' },
})
)
);
return updatedAlerts as Array<Record<string, unknown>>;
};
beforeEach(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts');
});
afterEach(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/rule_registry/alerts');
});
it('removes a case from the alert schema when deleting an alert attachment', async () => {
await createCaseAttachAlertAndDeleteAlert({
totalCases: 1,
indexOfCaseToDelete: 0,
owner: 'observabilityFixture',
alerts,
getAlerts,
});
});
it('should remove only one case', async () => {
await createCaseAttachAlertAndDeleteAlert({
totalCases: 3,
indexOfCaseToDelete: 1,
owner: 'observabilityFixture',
alerts,
getAlerts,
});
});
it('should delete case ID from the alert schema when the user has read access only', async () => {
await createCaseAttachAlertAndDeleteAlert({
totalCases: 1,
indexOfCaseToDelete: 0,
expectedHttpCode: 204,
owner: 'observabilityFixture',
alerts,
getAlerts,
deleteCommentAuth: { user: obsOnlyReadAlerts, space: 'space1' },
});
});
it('should NOT delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
await createCaseAttachAlertAndDeleteAlert({
totalCases: 1,
indexOfCaseToDelete: 0,
expectedHttpCode: 403,
owner: 'observabilityFixture',
alerts,
getAlerts,
deleteCommentAuth: { user: obsSec, space: 'space1' },
});
});
});
});
describe('rbac', () => {
afterEach(async () => {
await deleteAllCaseItems(es);
});

View file

@ -812,7 +812,7 @@ export default ({ getService }: FtrProviderContext): void => {
const caseIds = cases.map((theCase) => theCase.id);
expect(alert['kibana.alert.case_ids']).eql(caseIds);
expect(alert[ALERT_CASE_IDS]).eql(caseIds);
return { alert, cases };
};
@ -851,7 +851,7 @@ export default ({ getService }: FtrProviderContext): void => {
auth: { user: superUser, space: 'space1' },
});
expect(alert['kibana.alert.case_ids']).eql([postedCase.id]);
expect(alert[ALERT_CASE_IDS]).eql([postedCase.id]);
});
it('should not add more than 10 cases to an alert', async () => {