[Cases] Remove case id from alerts when deleting a case (#154829)

## Summary

This PR removes the case id from all alerts attached to a case when
deleting a case. It also removes any alert authorization when removing
alerts from a case.

### 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-18 19:20:59 +03:00 committed by GitHub
parent 136876091a
commit 42effff78d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 683 additions and 181 deletions

View file

@ -43,7 +43,7 @@ export interface UpdateAlertCasesRequest {
caseIds: string[];
}
export interface RemoveAlertsFromCaseRequest {
export interface RemoveCaseIdFromAlertsRequest {
alerts: AlertInfo[];
caseId: string;
}

View file

@ -25,10 +25,6 @@ describe('delete', () => {
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',
@ -39,7 +35,6 @@ describe('delete', () => {
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();
});
});
@ -66,14 +61,6 @@ describe('delete', () => {
it('delete alerts correctly', async () => {
await deleteAll({ caseID: 'mock-id-4' }, clientArgs);
expect(clientArgs.services.alertsService.ensureAlertsAuthorized).toHaveBeenCalledWith({
alerts: [
{ id: 'test-id', index: 'test-index' },
{ id: 'test-id-2', index: 'test-index-2' },
{ id: 'test-id-3', index: 'test-index-3' },
],
});
expect(clientArgs.services.alertsService.removeCaseIdFromAlerts).toHaveBeenCalledWith({
alerts: [
{ id: 'test-id', index: 'test-index' },

View file

@ -151,6 +151,5 @@ const handleAlerts = async ({ alertsService, attachments, caseId }: HandleAlerts
}
const alerts = getAlertInfoFromComments(alertAttachments);
await alertsService.ensureAlertsAuthorized({ alerts });
await alertsService.removeCaseIdFromAlerts({ alerts, caseId });
};

View file

@ -10,11 +10,14 @@ import { createFileServiceMock } from '@kbn/files-plugin/server/mocks';
import type { FileJSON } from '@kbn/shared-ux-file-types';
import type { CaseFileMetadataForDeletion } from '../../../common/files';
import { constructFileKindIdByOwner } from '../../../common/files';
import { getFileEntities } from './delete';
import { getFileEntities, deleteCases } from './delete';
import { createCasesClientMockArgs } from '../mocks';
import { mockCases } from '../../mocks';
const getCaseIds = (numIds: number) => {
return Array.from(Array(numIds).keys()).map((key) => key.toString());
};
describe('delete', () => {
describe('getFileEntities', () => {
const numCaseIds = 1000;
@ -66,6 +69,33 @@ describe('delete', () => {
expect(entities).toEqual(expectedEntities);
});
});
describe('deleteCases', () => {
const clientArgs = createCasesClientMockArgs();
clientArgs.fileService.find.mockResolvedValue({ files: [], total: 0 });
clientArgs.services.caseService.getCases.mockResolvedValue({
saved_objects: [mockCases[0], mockCases[1]],
});
clientArgs.services.attachmentService.getter.getAttachmentIdsForCases.mockResolvedValue([]);
clientArgs.services.userActionService.getUserActionIdsForCases.mockResolvedValue([]);
beforeEach(() => {
jest.clearAllMocks();
});
describe('alerts', () => {
const caseIds = ['mock-id-1', 'mock-id-2'];
it('removes the case ids from all alerts', async () => {
await deleteCases(caseIds, clientArgs);
expect(clientArgs.services.alertsService.removeCaseIdsFromAllAlerts).toHaveBeenCalledWith({
caseIds,
});
});
});
});
});
const createMockFileJSON = (): FileJSON => {

View file

@ -28,7 +28,7 @@ import { createFileEntities, deleteFiles } from '../files';
*/
export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): Promise<void> {
const {
services: { caseService, attachmentService, userActionService },
services: { caseService, attachmentService, userActionService, alertsService },
logger,
authorization,
fileService,
@ -75,6 +75,7 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P
entities: bulkDeleteEntities,
options: { refresh: 'wait_for' },
}),
alertsService.removeCaseIdsFromAllAlerts({ caseIds: ids }),
]);
await userActionService.creator.bulkAuditLogCaseDeletion(

View file

@ -423,5 +423,47 @@ describe('updateAlertsStatus', () => {
expect(alertsClient.removeCaseIdFromAlerts).not.toHaveBeenCalled();
});
it('should not throw an error and log it', async () => {
alertsClient.removeCaseIdFromAlerts.mockRejectedValueOnce('An error');
await expect(
alertService.removeCaseIdFromAlerts({ alerts, caseId })
).resolves.not.toThrowError();
expect(logger.error).toHaveBeenCalledWith(
'Failed removing case test-case from alerts: An error'
);
});
});
describe('removeCaseIdsFromAllAlerts', () => {
const caseIds = ['test-case-1', 'test-case-2'];
it('remove all case ids from alerts', async () => {
await alertService.removeCaseIdsFromAllAlerts({ caseIds });
expect(alertsClient.removeCaseIdsFromAllAlerts).toBeCalledWith({ caseIds });
});
it('does not call the alerts client with no case ids', async () => {
await alertService.removeCaseIdsFromAllAlerts({
caseIds: [],
});
expect(alertsClient.removeCaseIdsFromAllAlerts).not.toHaveBeenCalled();
});
it('should not throw an error and log it', async () => {
alertsClient.removeCaseIdsFromAllAlerts.mockRejectedValueOnce('An error');
await expect(
alertService.removeCaseIdsFromAllAlerts({ caseIds })
).resolves.not.toThrowError();
expect(logger.error).toHaveBeenCalledWith(
'Failed removing cases test-case-1,test-case-2 for all alerts: An error'
);
});
});
});

View file

@ -19,7 +19,7 @@ import { MAX_ALERTS_PER_CASE, MAX_CONCURRENT_SEARCHES } from '../../../common/co
import { createCaseError } from '../../common/error';
import type { AlertInfo } from '../../common/types';
import type {
RemoveAlertsFromCaseRequest,
RemoveCaseIdFromAlertsRequest,
UpdateAlertCasesRequest,
UpdateAlertStatusRequest,
} from '../../client/alerts/types';
@ -237,7 +237,7 @@ export class AlertService {
public async removeCaseIdFromAlerts({
alerts,
caseId,
}: RemoveAlertsFromCaseRequest): Promise<void> {
}: RemoveCaseIdFromAlertsRequest): Promise<void> {
try {
const nonEmptyAlerts = this.getNonEmptyAlerts(alerts);
@ -250,11 +250,31 @@ export class AlertService {
caseId,
});
} catch (error) {
throw createCaseError({
message: `Failed to remove case ${caseId} from alerts: ${error}`,
error,
logger: this.logger,
/**
* We intentionally do not throw an error.
* Users should be able to remove alerts from a case even
* in the event of an error produced by the alerts client
*/
this.logger.error(`Failed removing case ${caseId} from alerts: ${error}`);
}
}
public async removeCaseIdsFromAllAlerts({ caseIds }: { caseIds: string[] }): Promise<void> {
try {
if (caseIds.length <= 0) {
return;
}
await this.alertsClient.removeCaseIdsFromAllAlerts({
caseIds,
});
} catch (error) {
/**
* We intentionally do not throw an error.
* Users should be able to remove alerts from cases even
* in the event of an error produced by the alerts client
*/
this.logger.error(`Failed removing cases ${caseIds} for all alerts: ${error}`);
}
}

View file

@ -45,7 +45,7 @@ export type LicensingServiceMock = jest.Mocked<LicensingService>;
export type NotificationServiceMock = jest.Mocked<EmailNotificationService>;
export const createCaseServiceMock = (): CaseServiceMock => {
const service = {
const service: PublicMethodsOf<CaseServiceMock> = {
deleteCase: jest.fn(),
findCases: jest.fn(),
getAllCaseComments: jest.fn(),
@ -61,6 +61,7 @@ export const createCaseServiceMock = (): CaseServiceMock => {
findCasesGroupedByID: jest.fn(),
getCaseStatusStats: jest.fn(),
executeAggregations: jest.fn(),
bulkDeleteCaseEntities: jest.fn(),
};
// the cast here is required because jest.Mocked tries to include private members and would throw an error
@ -140,6 +141,7 @@ export const createAlertServiceMock = (): AlertServiceMock => {
bulkUpdateCases: jest.fn(),
ensureAlertsAuthorized: jest.fn(),
removeCaseIdFromAlerts: jest.fn(),
removeCaseIdsFromAllAlerts: jest.fn(),
};
// the cast here is required because jest.Mocked tries to include private members and would throw an error

View file

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

View file

@ -105,7 +105,7 @@ export interface BulkUpdateCasesOptions {
caseIds: string[];
}
export interface RemoveAlertsFromCaseOptions {
export interface RemoveCaseIdFromAlertsOptions {
alerts: MgetAndAuditAlert[];
caseId: string;
}
@ -851,33 +851,33 @@ export class AlertsClient {
});
}
public async removeCaseIdFromAlerts({ caseId, alerts }: RemoveAlertsFromCaseOptions) {
public async removeCaseIdFromAlerts({ caseId, alerts }: RemoveCaseIdFromAlertsOptions) {
/**
* We intentionally do not perform any authorization
* on the alerts. Users should be able to remove
* cases from alerts when deleting a case or an
* attachment
*/
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);
}
if (ctx._source['${ALERT_CASE_IDS}'].contains('${caseId}')) {
int index = ctx._source['${ALERT_CASE_IDS}'].indexOf('${caseId}');
ctx._source['${ALERT_CASE_IDS}'].remove(index);
}
}`;
const bulkUpdateRequest = [];
for (const doc of mgetRes.docs) {
for (const alert of alerts) {
bulkUpdateRequest.push(
{
update: {
_index: doc._index,
_id: doc._id,
_index: alert.index,
_id: alert.id,
},
},
{
@ -891,11 +891,61 @@ export class AlertsClient {
body: bulkUpdateRequest,
});
} catch (error) {
this.logger.error(`Error to remove case ${caseId} from alerts: ${error}`);
this.logger.error(`Error removing case ${caseId} from alerts: ${error}`);
throw error;
}
}
public async removeCaseIdsFromAllAlerts({ caseIds }: { caseIds: string[] }) {
/**
* We intentionally do not perform any authorization
* on the alerts. Users should be able to remove
* cases from alerts when deleting a case or an
* attachment
*/
try {
if (caseIds.length === 0) {
return;
}
const index = `${this.ruleDataService.getResourcePrefix()}-*`;
const query = `${ALERT_CASE_IDS}: (${caseIds.join(' or ')})`;
const esQuery = buildEsQuery(undefined, { query, language: 'kuery' }, []);
const SCRIPT_PARAMS_ID = 'caseIds';
const painlessScript = `if (ctx._source['${ALERT_CASE_IDS}'] != null && ctx._source['${ALERT_CASE_IDS}'].length > 0 && params['${SCRIPT_PARAMS_ID}'] != null && params['${SCRIPT_PARAMS_ID}'].length > 0) {
List storedCaseIds = ctx._source['${ALERT_CASE_IDS}'];
List caseIdsToRemove = params['${SCRIPT_PARAMS_ID}'];
for (int i=0; i < caseIdsToRemove.length; i++) {
if (storedCaseIds.contains(caseIdsToRemove[i])) {
int index = storedCaseIds.indexOf(caseIdsToRemove[i]);
storedCaseIds.remove(index);
}
}
}`;
await this.esClient.updateByQuery({
index,
conflicts: 'proceed',
refresh: true,
body: {
script: {
source: painlessScript,
lang: 'painless',
params: { caseIds },
} as InlineScript,
query: esQuery,
},
ignore_unavailable: true,
});
} catch (err) {
this.logger.error(`Failed removing ${caseIds} from all alerts: ${err}`);
throw err;
}
}
public async find<Params extends RuleTypeParams = never>({
aggs,
featureIds,

View file

@ -1,109 +0,0 @@
/*
* 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

@ -0,0 +1,179 @@
/*
* 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 { AlertsClient, ConstructorOptions } from '../alerts_client';
import { ruleDataServiceMock } from '../../rule_data_plugin_service/rule_data_plugin_service.mock';
describe('remove cases from alerts', () => {
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();
});
it('removes alerts from a case', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.removeCaseIdFromAlerts({ caseId, alerts });
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) {
if (ctx._source['kibana.alert.case_ids'].contains('test-case')) {
int index = ctx._source['kibana.alert.case_ids'].indexOf('test-case');
ctx._source['kibana.alert.case_ids'].remove(index);
}
}",
},
},
],
"refresh": "wait_for",
}
`);
});
it('does not do any calls if there are no alerts', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.removeCaseIdFromAlerts({ caseId, alerts: [] });
expect(esClientMock.bulk).not.toHaveBeenCalled();
});
});
describe('removeCaseIdsFromAllAlerts', () => {
const alertingAuthMock = alertingAuthorizationMock.create();
const esClientMock = elasticsearchClientMock.createElasticsearchClient();
const auditLogger = auditLoggerMock.create();
const caseIds = ['test-case-1', 'test-case-2'];
const alertsClientParams: jest.Mocked<ConstructorOptions> = {
logger: loggingSystemMock.create().get(),
authorization: alertingAuthMock,
esClient: esClientMock,
auditLogger,
ruleDataService: ruleDataServiceMock.create(),
};
beforeEach(() => {
jest.clearAllMocks();
});
it('removes alerts from a case', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.removeCaseIdsFromAllAlerts({ caseIds });
expect(esClientMock.updateByQuery.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"body": Object {
"query": Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"kibana.alert.case_ids": "test-case-1",
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"kibana.alert.case_ids": "test-case-2",
},
},
],
},
},
],
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
"script": Object {
"lang": "painless",
"params": Object {
"caseIds": Array [
"test-case-1",
"test-case-2",
],
},
"source": "if (ctx._source['kibana.alert.case_ids'] != null && ctx._source['kibana.alert.case_ids'].length > 0 && params['caseIds'] != null && params['caseIds'].length > 0) {
List storedCaseIds = ctx._source['kibana.alert.case_ids'];
List caseIdsToRemove = params['caseIds'];
for (int i=0; i < caseIdsToRemove.length; i++) {
if (storedCaseIds.contains(caseIdsToRemove[i])) {
int index = storedCaseIds.indexOf(caseIdsToRemove[i]);
storedCaseIds.remove(index);
}
}
}",
},
},
"conflicts": "proceed",
"ignore_unavailable": true,
"index": "undefined-*",
"refresh": true,
}
`);
});
it('does not do any calls if there are no cases', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
await alertsClient.removeCaseIdsFromAllAlerts({ caseIds: [] });
expect(esClientMock.updateByQuery).not.toHaveBeenCalled();
});
});
});

View file

@ -12,7 +12,7 @@ import { ToolingLog } from '@kbn/tooling-log';
import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '@kbn/security-solution-plugin/common/constants';
import { DetectionAlert } from '@kbn/security-solution-plugin/common/detection_engine/schemas/alerts';
import { RiskEnrichmentFields } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/enrichments/types';
import { CommentType } from '@kbn/cases-plugin/common';
import { CaseResponse, CommentType } from '@kbn/cases-plugin/common';
import { ALERT_CASE_IDS } from '@kbn/rule-data-utils';
import {
getRuleForSignalTesting,
@ -25,7 +25,7 @@ import {
import { superUser } from './authentication/users';
import { User } from './authentication/types';
import { getSpaceUrlPrefix } from './api/helpers';
import { createCase } from './api/case';
import { createCase, deleteCases } from './api/case';
import { createComment, deleteAllComments } from './api';
import { postCaseReq } from './mock';
@ -102,6 +102,100 @@ export const createCaseAttachAlertAndDeleteAlert = async ({
alerts: Alerts;
getAlerts: (alerts: Alerts) => Promise<Array<Record<string, unknown>>>;
}) => {
const updatedCases = await createCaseAndAttachAlert({
supertest,
totalCases,
owner,
alerts,
getAlerts,
});
const caseToDelete = updatedCases[indexOfCaseToDelete];
await deleteAllComments({
supertest,
caseId: caseToDelete.id,
expectedHttpCode,
auth: deleteCommentAuth,
});
const alertAfterDeletion = await getAlerts(alerts);
const caseIdsWithoutRemovedCase = getCaseIdsWithoutRemovedCases({
expectedHttpCode,
updatedCases,
caseIdsToDelete: [caseToDelete.id],
});
for (const alert of alertAfterDeletion) {
expect(alert[ALERT_CASE_IDS]).eql(caseIdsWithoutRemovedCase);
}
};
export const createCaseAttachAlertAndDeleteCase = async ({
supertest,
totalCases,
indicesOfCaseToDelete,
owner,
expectedHttpCode = 204,
deleteCaseAuth = { user: superUser, space: 'space1' },
alerts,
getAlerts,
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
totalCases: number;
indicesOfCaseToDelete: number[];
owner: string;
expectedHttpCode?: number;
deleteCaseAuth?: { user: User; space: string | null };
alerts: Alerts;
getAlerts: (alerts: Alerts) => Promise<Array<Record<string, unknown>>>;
}) => {
const updatedCases = await createCaseAndAttachAlert({
supertest,
totalCases,
owner,
alerts,
getAlerts,
});
const casesToDelete = updatedCases.filter((_, filterIndex) =>
indicesOfCaseToDelete.some((indexToDelete) => indexToDelete === filterIndex)
);
const caseIdsToDelete = casesToDelete.map((theCase) => theCase.id);
await deleteCases({
supertest,
caseIDs: caseIdsToDelete,
expectedHttpCode,
auth: deleteCaseAuth,
});
const alertAfterDeletion = await getAlerts(alerts);
const caseIdsWithoutRemovedCase = getCaseIdsWithoutRemovedCases({
expectedHttpCode,
updatedCases,
caseIdsToDelete,
});
for (const alert of alertAfterDeletion) {
expect(alert[ALERT_CASE_IDS]).eql(caseIdsWithoutRemovedCase);
}
};
export const createCaseAndAttachAlert = async ({
supertest,
totalCases,
owner,
alerts,
getAlerts,
}: {
supertest: SuperTest.SuperTest<SuperTest.Test>;
totalCases: number;
owner: string;
alerts: Alerts;
getAlerts: (alerts: Alerts) => Promise<Array<Record<string, unknown>>>;
}): Promise<CaseResponse[]> => {
const cases = await Promise.all(
[...Array(totalCases).keys()].map((index) =>
createCase(
@ -147,25 +241,21 @@ export const createCaseAttachAlertAndDeleteAlert = async ({
expect(alert[ALERT_CASE_IDS]).eql(caseIds);
}
const caseToDelete = updatedCases[indexOfCaseToDelete];
await deleteAllComments({
supertest,
caseId: caseToDelete.id,
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);
}
return updatedCases;
};
export const getCaseIdsWithoutRemovedCases = ({
updatedCases,
caseIdsToDelete,
expectedHttpCode,
}: {
expectedHttpCode: number;
updatedCases: Array<{ id: string }>;
caseIdsToDelete: string[];
}) => {
return expectedHttpCode === 204
? updatedCases
.filter((theCase) => !caseIdsToDelete.some((id) => theCase.id === id))
.map((theCase) => theCase.id)
: updatedCases.map((theCase) => theCase.id);
};

View file

@ -8,6 +8,13 @@
import expect from '@kbn/expect';
import type SuperTest from 'supertest';
import { MAX_DOCS_PER_PAGE } from '@kbn/cases-plugin/common/constants';
import {
Alerts,
createCaseAttachAlertAndDeleteCase,
createSecuritySolutionAlerts,
getAlertById,
getSecuritySolutionAlerts,
} from '../../../../common/lib/alerts';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
@ -39,6 +46,10 @@ import {
noKibanaPrivileges,
obsOnly,
superUser,
obsOnlyReadAlerts,
obsSec,
secSolutionOnlyReadNoIndexAlerts,
secOnlyReadAlerts,
} from '../../../../common/lib/authentication/users';
import {
secAllUser,
@ -51,12 +62,19 @@ import {
SECURITY_SOLUTION_FILE_KIND,
} from '../../../../common/lib/constants';
import { User } from '../../../../common/lib/authentication/types';
import {
createSignalsIndex,
deleteAllRules,
deleteSignalsIndex,
} from '../../../../../detection_engine_api_integration/utils';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
const supertestWithoutAuth = getService('supertestWithoutAuth');
const supertest = getService('supertest');
const es = getService('es');
const esArchiver = getService('esArchiver');
const log = getService('log');
describe('delete_cases', () => {
afterEach(async () => {
@ -209,6 +227,197 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
describe('alerts', () => {
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 a case', async () => {
await createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 1,
indicesOfCaseToDelete: [0],
owner: 'securitySolutionFixture',
alerts,
getAlerts,
});
});
it('removes multiple cases from the alert schema when deleting all cases', async () => {
await createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 2,
indicesOfCaseToDelete: [0, 1],
owner: 'securitySolutionFixture',
alerts,
getAlerts,
});
});
it('removes multiple cases from the alert schema when deleting multiple cases', async () => {
await createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 4,
indicesOfCaseToDelete: [0, 2],
owner: 'securitySolutionFixture',
alerts,
getAlerts,
});
});
it('should delete case ID from the alert schema when the user has read access only', async () => {
await createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 1,
indicesOfCaseToDelete: [0],
expectedHttpCode: 204,
owner: 'securitySolutionFixture',
alerts,
getAlerts,
deleteCaseAuth: { user: secOnlyReadAlerts, space: 'space1' },
});
});
it('should delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
await createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 1,
indicesOfCaseToDelete: [0],
expectedHttpCode: 204,
owner: 'securitySolutionFixture',
alerts,
getAlerts,
deleteCaseAuth: { 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 createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 1,
indicesOfCaseToDelete: [0],
owner: 'securitySolutionFixture',
alerts,
getAlerts,
expectedHttpCode: 204,
deleteCaseAuth: { 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) => {
await es.indices.refresh({ index: '.alerts-observability.apm.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 a case', async () => {
await createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 1,
indicesOfCaseToDelete: [0],
owner: 'observabilityFixture',
alerts,
getAlerts,
});
});
it('removes multiple cases from the alert schema when deleting all cases', async () => {
await createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 2,
indicesOfCaseToDelete: [0, 1],
owner: 'observabilityFixture',
alerts,
getAlerts,
});
});
it('removes multiple cases from the alert schema when deleting multiple cases', async () => {
await createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 4,
indicesOfCaseToDelete: [0, 2],
owner: 'observabilityFixture',
alerts,
getAlerts,
});
});
it('should delete case ID from the alert schema when the user has read access only', async () => {
await createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 1,
indicesOfCaseToDelete: [0],
expectedHttpCode: 204,
owner: 'observabilityFixture',
alerts,
getAlerts,
deleteCaseAuth: { user: obsOnlyReadAlerts, space: 'space1' },
});
});
it('should delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
await createCaseAttachAlertAndDeleteCase({
supertest: supertestWithoutAuth,
totalCases: 1,
indicesOfCaseToDelete: [0],
expectedHttpCode: 204,
owner: 'observabilityFixture',
alerts,
getAlerts,
deleteCaseAuth: { user: obsSec, space: 'space1' },
});
});
});
});
describe('rbac', () => {
describe('files', () => {
// we need api_int_users and roles because they have authorization for the actual plugins (not the fixtures). This

View file

@ -7,6 +7,7 @@
import expect from '@kbn/expect';
import {
Alerts,
createCaseAttachAlertAndDeleteAlert,
createSecuritySolutionAlerts,
getAlertById,
@ -109,8 +110,6 @@ export default ({ getService }: FtrProviderContext): void => {
});
describe('alerts', () => {
type Alerts = Array<{ _id: string; _index: string }>;
describe('security_solution', () => {
let alerts: Alerts = [];
@ -172,7 +171,7 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
it('should NOT delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
it('should delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
await createCaseAttachAlertAndDeleteAlert({
supertest: supertestWithoutAuth,
totalCases: 1,
@ -180,7 +179,7 @@ export default ({ getService }: FtrProviderContext): void => {
owner: 'securitySolutionFixture',
alerts,
getAlerts,
expectedHttpCode: 403,
expectedHttpCode: 204,
deleteCommentAuth: { user: obsSec, space: 'space1' },
});
});
@ -206,6 +205,7 @@ export default ({ getService }: FtrProviderContext): void => {
];
const getAlerts = async (_alerts: Alerts) => {
await es.indices.refresh({ index: '.alerts-observability.apm.alerts' });
const updatedAlerts = await Promise.all(
_alerts.map((alert) =>
getAlertById({
@ -263,12 +263,12 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
it('should NOT delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
it('should delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
await createCaseAttachAlertAndDeleteAlert({
supertest: supertestWithoutAuth,
totalCases: 1,
indexOfCaseToDelete: 0,
expectedHttpCode: 403,
expectedHttpCode: 204,
owner: 'observabilityFixture',
alerts,
getAlerts,

View file

@ -214,7 +214,7 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
it('should NOT delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
it('should delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
await createCaseAttachAlertAndDeleteAlert({
supertest: supertestWithoutAuth,
totalCases: 1,
@ -222,7 +222,7 @@ export default ({ getService }: FtrProviderContext): void => {
owner: 'securitySolutionFixture',
alerts,
getAlerts,
expectedHttpCode: 403,
expectedHttpCode: 204,
deleteCommentAuth: { user: obsSec, space: 'space1' },
});
});
@ -248,6 +248,7 @@ export default ({ getService }: FtrProviderContext): void => {
];
const getAlerts = async (_alerts: Alerts) => {
await es.indices.refresh({ index: '.alerts-observability.apm.alerts' });
const updatedAlerts = await Promise.all(
_alerts.map((alert) =>
getAlertById({
@ -346,12 +347,12 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
it('should NOT delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
it('should delete case ID from the alert schema when the user does NOT have access to the alert', async () => {
await createCaseAttachAlertAndDeleteAlert({
supertest: supertestWithoutAuth,
totalCases: 1,
indexOfCaseToDelete: 0,
expectedHttpCode: 403,
expectedHttpCode: 204,
owner: 'observabilityFixture',
alerts,
getAlerts,