[Cases] Delete Cases API Guardrails (#160846)

Connected to #146945

## Summary

| Description  | Limit | Done? | Documented?
| ------------- | ---- | :---: | ---- |
| Total number of cases to be deleted | 100 |  | Yes |

- Used schema validation.
- Updated documentation.
- Added jest and e2e tests.

### Checklist

Delete any items that are not applicable to this PR.

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

### Release notes

The Delete cases API now limits the number of cases to be deleted to
100.
This commit is contained in:
Antonio 2023-06-30 14:40:12 +02:00 committed by GitHub
parent 646539c45b
commit b12238bac8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 73 additions and 15 deletions

View file

@ -13,12 +13,13 @@ import { CommentRt } from './comment';
import { CasesStatusResponseRt, CaseStatusRt } from './status';
import { CaseConnectorRt } from '../connectors/connector';
import { CaseAssigneesRt } from './assignee';
import { limitedArraySchema, NonEmptyString } from '../../schema';
import {
MAX_DELETE_IDS_LENGTH,
MAX_ASSIGNEES_FILTER_LENGTH,
MAX_REPORTERS_FILTER_LENGTH,
MAX_TAGS_FILTER_LENGTH,
} from '../../constants';
import { limitedArraySchema } from '../../schema';
export const AttachmentTotalsRt = rt.strict({
alerts: rt.number,
@ -295,6 +296,13 @@ export const CasesFindRequestRt = rt.exact(
})
);
export const CasesDeleteRequestRt = limitedArraySchema(
NonEmptyString,
1,
MAX_DELETE_IDS_LENGTH,
'ids'
);
export const CasesByAlertIDRequestRt = rt.exact(
rt.partial({
/**
@ -432,6 +440,7 @@ export type CasePostRequest = rt.TypeOf<typeof CasePostRequestRt>;
export type Case = rt.TypeOf<typeof CaseRt>;
export type CaseResolveResponse = rt.TypeOf<typeof CaseResolveResponseRt>;
export type Cases = rt.TypeOf<typeof CasesRt>;
export type CasesDeleteRequest = rt.TypeOf<typeof CasesDeleteRequestRt>;
export type CasesFindRequest = rt.TypeOf<typeof CasesFindRequestRt>;
export type CasesByAlertIDRequest = rt.TypeOf<typeof CasesByAlertIDRequestRt>;
export type CasesFindResponse = rt.TypeOf<typeof CasesFindResponseRt>;

View file

@ -116,6 +116,7 @@ export const MAX_REPORTERS_FILTER_LENGTH = 100 as const;
export const MAX_TITLE_LENGTH = 160 as const;
export const MAX_CATEGORY_LENGTH = 50 as const;
export const MAX_DELETE_IDS_LENGTH = 100 as const;
/**
* Cases features

View file

@ -3809,7 +3809,12 @@
"in": "query",
"required": true,
"schema": {
"type": "string"
"type": "array",
"items": {
"type": "string",
"minItems": 1,
"maxItems": 100
}
},
"example": "d4e7abb0-b462-11ec-9a8d-698504725a43"
},

View file

@ -2310,7 +2310,11 @@ components:
in: query
required: true
schema:
type: string
type: array
items:
type: string
minItems: 1
maxItems: 100
example: d4e7abb0-b462-11ec-9a8d-698504725a43
assignees:
in: query

View file

@ -5,8 +5,9 @@ description: >
in: query
required: true
schema:
type: string
example: d4e7abb0-b462-11ec-9a8d-698504725a43
type: array
items:
type: string
minItems: 1
maxItems: 100
example: d4e7abb0-b462-11ec-9a8d-698504725a43

View file

@ -23,7 +23,7 @@ post:
'200':
description: Indicates a successful call.
content:
application/json:
application/json:
schema:
$ref: '../components/schemas/case_response_properties.yaml'
examples:
@ -36,7 +36,7 @@ post:
schema:
$ref: '../components/schemas/4xx_response.yaml'
servers:
- url: https://localhost:5601
- url: https://localhost:5601
delete:
summary: Deletes one or more cases.
@ -103,7 +103,7 @@ patch:
schema:
$ref: '../components/schemas/4xx_response.yaml'
servers:
- url: https://localhost:5601
- url: https://localhost:5601
servers:
- url: https://localhost:5601
- url: https://localhost:5601

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { MAX_FILES_PER_CASE } from '../../../common/constants';
import { MAX_DELETE_IDS_LENGTH, MAX_FILES_PER_CASE } from '../../../common/constants';
import type { FindFileArgs } from '@kbn/files-plugin/server';
import { createFileServiceMock } from '@kbn/files-plugin/server/mocks';
import type { FileJSON } from '@kbn/shared-ux-file-types';
@ -95,6 +95,22 @@ describe('delete', () => {
});
});
});
describe('errors', () => {
it(`throws 400 when trying to delete more than ${MAX_DELETE_IDS_LENGTH} cases at a time`, async () => {
const caseIds = new Array(MAX_DELETE_IDS_LENGTH + 1).fill('id');
await expect(deleteCases(caseIds, clientArgs)).rejects.toThrowError(
'Error: The length of the field ids is too long. Array must be of length <= 100.'
);
});
it('throws 400 when no id is passed to delete', async () => {
await expect(deleteCases([], clientArgs)).rejects.toThrowError(
'Error: The length of the field ids is too short. Array must be of length >= 1.'
);
});
});
});
});

View file

@ -10,6 +10,8 @@ import pMap from 'p-map';
import { chunk } from 'lodash';
import type { SavedObjectsBulkDeleteObject } from '@kbn/core/server';
import type { FileServiceStart } from '@kbn/files-plugin/server';
import type { CasesDeleteRequest } from '../../../common/api';
import { CasesDeleteRequestRt, decodeWithExcessOrThrow } from '../../../common/api';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
@ -26,7 +28,10 @@ import { createFileEntities, deleteFiles } from '../files';
/**
* Deletes the specified cases and their attachments.
*/
export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): Promise<void> {
export async function deleteCases(
ids: CasesDeleteRequest,
clientArgs: CasesClientArgs
): Promise<void> {
const {
services: { caseService, attachmentService, userActionService, alertsService },
logger,
@ -35,7 +40,8 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P
} = clientArgs;
try {
const cases = await caseService.getCases({ caseIds: ids });
const caseIds = decodeWithExcessOrThrow(CasesDeleteRequestRt)(ids);
const cases = await caseService.getCases({ caseIds });
const entities = new Map<string, OwnerEntity>();
for (const theCase of cases.saved_objects) {

View file

@ -143,6 +143,22 @@ export default ({ getService }: FtrProviderContext): void => {
await deleteCases({ supertest, caseIDs: ['fake-id'], expectedHttpCode: 404 });
});
it('unhappy path - 400s when trying to delete more than 100 cases at a time', async () => {
await deleteCases({
supertest: supertestWithoutAuth,
caseIDs: new Array(101).fill('id'),
expectedHttpCode: 400,
});
});
it('unhappy path - 400s when trying to delete 0 cases at a time', async () => {
await deleteCases({
supertest: supertestWithoutAuth,
caseIDs: [],
expectedHttpCode: 400,
});
});
describe('files', () => {
afterEach(async () => {
await deleteAllFiles({