diff --git a/src/plugins/files/server/file_service/file_action_types.ts b/src/plugins/files/server/file_service/file_action_types.ts index c1264ed2b2452..4247f567802ed 100644 --- a/src/plugins/files/server/file_service/file_action_types.ts +++ b/src/plugins/files/server/file_service/file_action_types.ts @@ -105,5 +105,5 @@ export interface FindFileArgs extends Pagination { /** * File metadata values. These values are governed by the consumer. */ - meta?: Record; + meta?: Record; } diff --git a/x-pack/plugins/cases/common/api/helpers.ts b/x-pack/plugins/cases/common/api/helpers.ts index 95180c9695950..b02d46e85fb18 100644 --- a/x-pack/plugins/cases/common/api/helpers.ts +++ b/x-pack/plugins/cases/common/api/helpers.ts @@ -21,6 +21,7 @@ import { INTERNAL_CONNECTORS_URL, INTERNAL_CASE_USERS_URL, INTERNAL_DELETE_FILE_ATTACHMENTS_URL, + CASE_FIND_ATTACHMENTS_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -39,6 +40,10 @@ export const getCaseCommentDetailsUrl = (caseId: string, commentId: string): str return CASE_COMMENT_DETAILS_URL.replace('{case_id}', caseId).replace('{comment_id}', commentId); }; +export const getCaseFindAttachmentsUrl = (caseId: string): string => { + return CASE_FIND_ATTACHMENTS_URL.replace('{case_id}', caseId); +}; + export const getCaseCommentDeleteUrl = (caseId: string, commentId: string): string => { return CASE_COMMENT_DELETE_URL.replace('{case_id}', caseId).replace('{comment_id}', commentId); }; diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index b0bf34eef6c29..83bd0cb660093 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -47,6 +47,7 @@ export const CASE_CONFIGURE_DETAILS_URL = `${CASES_URL}/configure/{configuration export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors` as const; export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments` as const; +export const CASE_FIND_ATTACHMENTS_URL = `${CASE_COMMENTS_URL}/_find` as const; export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}` as const; export const CASE_COMMENT_DELETE_URL = `${CASE_DETAILS_URL}/comments/{comment_id}` as const; export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push` as const; diff --git a/x-pack/plugins/cases/common/files/index.ts b/x-pack/plugins/cases/common/files/index.ts index 6ab2268eeb0ce..2e72430a24d88 100644 --- a/x-pack/plugins/cases/common/files/index.ts +++ b/x-pack/plugins/cases/common/files/index.ts @@ -18,7 +18,7 @@ export const CaseFileMetadataForDeletionRt = rt.type({ caseIds: rt.array(rt.string), }); -export type CaseFileMetadata = rt.TypeOf; +export type CaseFileMetadataForDeletion = rt.TypeOf; const FILE_KIND_DELIMITER = 'FilesCases'; diff --git a/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts b/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts index c604c13b0970f..75cdf6f42e05f 100644 --- a/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts +++ b/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts @@ -12,18 +12,18 @@ import { identity } from 'fp-ts/lib/function'; import pMap from 'p-map'; import { partition } from 'lodash'; -import type { File } from '@kbn/files-plugin/common'; +import type { File, FileJSON } from '@kbn/files-plugin/common'; import type { FileServiceStart } from '@kbn/files-plugin/server'; import { FileNotFoundError } from '@kbn/files-plugin/server/file_service/errors'; import { BulkDeleteFileAttachmentsRequestRt, excess, throwErrors } from '../../../common/api'; import { MAX_CONCURRENT_SEARCHES } from '../../../common/constants'; import type { CasesClientArgs } from '../types'; import { createCaseError } from '../../common/error'; -import type { OwnerEntity } from '../../authorization'; import { Operations } from '../../authorization'; import type { BulkDeleteFileArgs } from './types'; -import { constructOwnerFromFileKind, CaseFileMetadataForDeletionRt } from '../../../common/files'; +import { CaseFileMetadataForDeletionRt } from '../../../common/files'; import type { CasesClient } from '../client'; +import { createFileEntities, deleteFiles } from '../files'; export const bulkDeleteFileAttachments = async ( { caseId, fileIds }: BulkDeleteFileArgs, @@ -67,9 +67,7 @@ export const bulkDeleteFileAttachments = async ( }); await Promise.all([ - pMap(request.ids, async (fileId: string) => fileService.delete({ id: fileId }), { - concurrency: MAX_CONCURRENT_SEARCHES, - }), + deleteFiles(request.ids, fileService), attachmentService.bulkDelete({ attachmentIds: fileAttachments.map((so) => so.id), refresh: false, @@ -117,7 +115,7 @@ const getFiles = async ( caseId: BulkDeleteFileArgs['caseId'], fileIds: BulkDeleteFileArgs['fileIds'], fileService: FileServiceStart -) => { +): Promise => { // it's possible that we're trying to delete a file when an attachment wasn't created (for example if the create // attachment request failed) const files = await pMap(fileIds, async (fileId: string) => fileService.getById({ id: fileId }), { @@ -143,25 +141,5 @@ const getFiles = async ( throw Boom.badRequest('Failed to find files to delete'); } - return validFiles; -}; - -const createFileEntities = (files: File[]) => { - const fileEntities: OwnerEntity[] = []; - - // It's possible that the owner array could have invalid information in it so we'll use the file kind for determining if the user - // has the correct authorization for deleting these files - for (const fileInfo of files) { - const ownerFromFileKind = constructOwnerFromFileKind(fileInfo.data.fileKind); - - if (ownerFromFileKind == null) { - throw Boom.badRequest( - `File id ${fileInfo.id} has invalid file kind ${fileInfo.data.fileKind}` - ); - } - - fileEntities.push({ id: fileInfo.id, owner: ownerFromFileKind }); - } - - return fileEntities; + return validFiles.map((fileInfo) => fileInfo.data); }; diff --git a/x-pack/plugins/cases/server/client/cases/delete.test.ts b/x-pack/plugins/cases/server/client/cases/delete.test.ts new file mode 100644 index 0000000000000..043b08733dd3f --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/delete.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { 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'; +import type { CaseFileMetadataForDeletion } from '../../../common/files'; +import { constructFileKindIdByOwner } from '../../../common/files'; +import { getFileEntities } from './delete'; + +const getCaseIds = (numIds: number) => { + return Array.from(Array(numIds).keys()).map((key) => key.toString()); +}; +describe('delete', () => { + describe('getFileEntities', () => { + const numCaseIds = 1000; + const caseIds = getCaseIds(numCaseIds); + const mockFileService = createFileServiceMock(); + mockFileService.find.mockImplementation(async (args: FindFileArgs) => { + const caseMeta = args.meta as unknown as CaseFileMetadataForDeletion; + const numFilesToGen = caseMeta.caseIds.length * MAX_FILES_PER_CASE; + const files = Array.from(Array(numFilesToGen).keys()).map(() => createMockFileJSON()); + + return { + files, + total: files.length, + }; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('only provides 50 case ids in a single call to the find api', async () => { + await getFileEntities(caseIds, mockFileService); + + for (const call of mockFileService.find.mock.calls) { + const callMeta = call[0].meta as unknown as CaseFileMetadataForDeletion; + expect(callMeta.caseIds.length).toEqual(50); + } + }); + + it('calls the find function the number of case ids divided by the chunk size', async () => { + await getFileEntities(caseIds, mockFileService); + + const chunkSize = 50; + + expect(mockFileService.find).toHaveBeenCalledTimes(numCaseIds / chunkSize); + }); + + it('returns the number of entities equal to the case ids times the max files per case limit', async () => { + const expectedEntities = Array.from(Array(numCaseIds * MAX_FILES_PER_CASE).keys()).map( + () => ({ + id: '123', + owner: 'securitySolution', + }) + ); + + const entities = await getFileEntities(caseIds, mockFileService); + + expect(entities.length).toEqual(numCaseIds * MAX_FILES_PER_CASE); + expect(entities).toEqual(expectedEntities); + }); + }); +}); + +const createMockFileJSON = (): FileJSON => { + return { + id: '123', + fileKind: constructFileKindIdByOwner('securitySolution'), + meta: { + owner: ['securitySolution'], + }, + } as unknown as FileJSON; +}; diff --git a/x-pack/plugins/cases/server/client/cases/delete.ts b/x-pack/plugins/cases/server/client/cases/delete.ts index d9e0b6383af1b..a81e0db4dcf66 100644 --- a/x-pack/plugins/cases/server/client/cases/delete.ts +++ b/x-pack/plugins/cases/server/client/cases/delete.ts @@ -6,27 +6,32 @@ */ import { Boom } from '@hapi/boom'; +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 { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, CASE_USER_ACTION_SAVED_OBJECT, + MAX_FILES_PER_CASE, + MAX_DOCS_PER_PAGE, } from '../../../common/constants'; import type { CasesClientArgs } from '..'; import { createCaseError } from '../../common/error'; import type { OwnerEntity } from '../../authorization'; import { Operations } from '../../authorization'; +import { createFileEntities, deleteFiles } from '../files'; /** * Deletes the specified cases and their attachments. - * - * @ignore */ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): Promise { const { services: { caseService, attachmentService, userActionService }, logger, authorization, + fileService, } = clientArgs; try { const cases = await caseService.getCases({ caseIds: ids }); @@ -44,9 +49,11 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P entities.set(theCase.id, { id: theCase.id, owner: theCase.attributes.owner }); } + const fileEntities = await getFileEntities(ids, fileService); + await authorization.ensureAuthorized({ operation: Operations.deleteCase, - entities: Array.from(entities.values()), + entities: [...Array.from(entities.values()), ...fileEntities], }); const attachmentIds = await attachmentService.getter.getAttachmentIdsForCases({ @@ -61,10 +68,14 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P ...userActionIds.map((id) => ({ id, type: CASE_USER_ACTION_SAVED_OBJECT })), ]; - await caseService.bulkDeleteCaseEntities({ - entities: bulkDeleteEntities, - options: { refresh: 'wait_for' }, - }); + const fileIds = fileEntities.map((entity) => entity.id); + await Promise.all([ + deleteFiles(fileIds, fileService), + caseService.bulkDeleteCaseEntities({ + entities: bulkDeleteEntities, + options: { refresh: 'wait_for' }, + }), + ]); await userActionService.creator.bulkAuditLogCaseDeletion( cases.saved_objects.map((caseInfo) => caseInfo.id) @@ -77,3 +88,29 @@ export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): P }); } } + +export const getFileEntities = async ( + caseIds: string[], + fileService: FileServiceStart +): Promise => { + // using 50 just to be safe, each case can have 100 files = 50 * 100 = 5000 which is half the max number of docs that + // the client can request + const chunkSize = MAX_FILES_PER_CASE / 2; + const chunkedIds = chunk(caseIds, chunkSize); + + const entityResults = await pMap(chunkedIds, async (ids: string[]) => { + const findRes = await fileService.find({ + perPage: MAX_DOCS_PER_PAGE, + meta: { + caseIds: ids, + }, + }); + + const fileEntities = createFileEntities(findRes.files); + return fileEntities; + }); + + const entities = entityResults.flatMap((res) => res); + + return entities; +}; diff --git a/x-pack/plugins/cases/server/client/files/index.test.ts b/x-pack/plugins/cases/server/client/files/index.test.ts new file mode 100644 index 0000000000000..e1727f5885d4a --- /dev/null +++ b/x-pack/plugins/cases/server/client/files/index.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { createFileServiceMock } from '@kbn/files-plugin/server/mocks'; +import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common'; +import { constructFileKindIdByOwner } from '../../../common/files'; +import { createFileEntities, deleteFiles } from '.'; + +describe('server files', () => { + describe('createFileEntities', () => { + it('returns an empty array when passed no files', () => { + expect(createFileEntities([])).toEqual([]); + }); + + it('throws an error when the file kind is not valid', () => { + expect.assertions(1); + + expect(() => + createFileEntities([{ fileKind: 'abc', id: '1' }]) + ).toThrowErrorMatchingInlineSnapshot(`"File id 1 has invalid file kind abc"`); + }); + + it('throws an error when one of the file entities does not have a valid file kind', () => { + expect.assertions(1); + + expect(() => + createFileEntities([ + { fileKind: constructFileKindIdByOwner(SECURITY_SOLUTION_OWNER), id: '1' }, + { fileKind: 'abc', id: '2' }, + ]) + ).toThrowErrorMatchingInlineSnapshot(`"File id 2 has invalid file kind abc"`); + }); + + it('returns an array of entities when the file kind is valid', () => { + expect.assertions(1); + + expect( + createFileEntities([ + { fileKind: constructFileKindIdByOwner(SECURITY_SOLUTION_OWNER), id: '1' }, + { fileKind: constructFileKindIdByOwner(OBSERVABILITY_OWNER), id: '2' }, + ]) + ).toEqual([ + { id: '1', owner: 'securitySolution' }, + { id: '2', owner: 'observability' }, + ]); + }); + }); + + describe('deleteFiles', () => { + it('calls delete twice with the ids passed in', async () => { + const fileServiceMock = createFileServiceMock(); + + expect.assertions(2); + await deleteFiles(['1', '2'], fileServiceMock); + + expect(fileServiceMock.delete).toBeCalledTimes(2); + expect(fileServiceMock.delete.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "id": "1", + }, + ], + Array [ + Object { + "id": "2", + }, + ], + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/files/index.ts b/x-pack/plugins/cases/server/client/files/index.ts new file mode 100644 index 0000000000000..31e0bcf8fb2dd --- /dev/null +++ b/x-pack/plugins/cases/server/client/files/index.ts @@ -0,0 +1,39 @@ +/* + * 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 Boom from '@hapi/boom'; +import type { FileJSON } from '@kbn/files-plugin/common'; +import type { FileServiceStart } from '@kbn/files-plugin/server'; +import pMap from 'p-map'; +import { constructOwnerFromFileKind } from '../../../common/files'; +import { MAX_CONCURRENT_SEARCHES } from '../../../common/constants'; +import type { OwnerEntity } from '../../authorization'; + +type FileEntityInfo = Pick; + +export const createFileEntities = (files: FileEntityInfo[]): OwnerEntity[] => { + const fileEntities: OwnerEntity[] = []; + + // It's possible that the owner array could have invalid information in it so we'll use the file kind for determining if the user + // has the correct authorization for deleting these files + for (const fileInfo of files) { + const ownerFromFileKind = constructOwnerFromFileKind(fileInfo.fileKind); + + if (ownerFromFileKind == null) { + throw Boom.badRequest(`File id ${fileInfo.id} has invalid file kind ${fileInfo.fileKind}`); + } + + fileEntities.push({ id: fileInfo.id, owner: ownerFromFileKind }); + } + + return fileEntities; +}; + +export const deleteFiles = async (fileIds: string[], fileService: FileServiceStart) => + pMap(fileIds, async (fileId: string) => fileService.delete({ id: fileId }), { + concurrency: MAX_CONCURRENT_SEARCHES, + }); diff --git a/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts b/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts index 23a7ecb601534..ede44d0784f89 100644 --- a/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts +++ b/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts @@ -13,13 +13,13 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { FindQueryParamsRt, throwErrors, excess } from '../../../../common/api'; -import { CASE_COMMENTS_URL } from '../../../../common/constants'; +import { CASE_FIND_ATTACHMENTS_URL } from '../../../../common/constants'; import { createCasesRoute } from '../create_cases_route'; import { createCaseError } from '../../../common/error'; export const findCommentsRoute = createCasesRoute({ method: 'get', - path: `${CASE_COMMENTS_URL}/_find`, + path: CASE_FIND_ATTACHMENTS_URL, params: { params: schema.object({ case_id: schema.string(), diff --git a/x-pack/test/cases_api_integration/common/lib/api/attachments.ts b/x-pack/test/cases_api_integration/common/lib/api/attachments.ts index 4f035a100e69f..c49a375ed0b94 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/attachments.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/attachments.ts @@ -15,7 +15,9 @@ import { CommentPatchRequest, CommentRequest, CommentResponse, + CommentsResponse, CommentType, + getCaseFindAttachmentsUrl, getCasesDeleteFileAttachmentsUrl, } from '@kbn/cases-plugin/common/api'; import { User } from '../authentication/types'; @@ -280,3 +282,26 @@ export const bulkDeleteFileAttachments = async ({ .auth(auth.user.username, auth.user.password) .expect(expectedHttpCode); }; + +export const findAttachments = async ({ + supertest, + caseId, + query = {}, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest; + caseId: string; + query?: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${getCaseFindAttachmentsUrl(caseId)}`) + .set('kbn-xsrf', 'true') + .query(query) + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); + + return body; +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts index 957f304b8e829..981a368019e8f 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts @@ -6,13 +6,16 @@ */ import expect from '@kbn/expect'; +import type SuperTest from 'supertest'; +import { MAX_DOCS_PER_PAGE } from '@kbn/cases-plugin/common/constants'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; -import { getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock'; import { - deleteCasesByESQuery, - deleteCasesUserActions, - deleteComments, + getFilesAttachmentReq, + getPostCaseRequest, + postCommentUserReq, +} from '../../../../common/lib/mock'; +import { createCase, deleteCases, createComment, @@ -20,6 +23,12 @@ import { getCase, superUserSpace1Auth, getCaseUserActions, + deleteAllCaseItems, + createAndUploadFile, + deleteAllFiles, + listFiles, + findAttachments, + bulkCreateAttachments, } from '../../../../common/lib/api'; import { secOnly, @@ -31,6 +40,17 @@ import { obsOnly, superUser, } from '../../../../common/lib/authentication/users'; +import { + secAllUser, + users as api_int_users, +} from '../../../../../api_integration/apis/cases/common/users'; +import { roles as api_int_roles } from '../../../../../api_integration/apis/cases/common/roles'; +import { createUsersAndRoles, deleteUsersAndRoles } from '../../../../common/lib/authentication'; +import { + OBSERVABILITY_FILE_KIND, + SECURITY_SOLUTION_FILE_KIND, +} from '../../../../common/lib/constants'; +import { User } from '../../../../common/lib/authentication/types'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -40,9 +60,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('delete_cases', () => { afterEach(async () => { - await deleteCasesByESQuery(es); - await deleteComments(es); - await deleteCasesUserActions(es); + await deleteAllCaseItems(es); }); it('should delete a case', async () => { @@ -106,7 +124,204 @@ export default ({ getService }: FtrProviderContext): void => { await deleteCases({ supertest, caseIDs: ['fake-id'], expectedHttpCode: 404 }); }); + describe('files', () => { + afterEach(async () => { + await deleteAllFiles({ + supertest, + }); + }); + + it('should delete all files associated with a case', async () => { + const { caseInfo: postedCase } = await createCaseWithFiles({ + supertest: supertestWithoutAuth, + fileKind: SECURITY_SOLUTION_FILE_KIND, + owner: 'securitySolution', + }); + + await deleteCases({ supertest: supertestWithoutAuth, caseIDs: [postedCase.id] }); + + const [filesAfterDelete, attachmentsAfterDelete] = await Promise.all([ + listFiles({ + supertest: supertestWithoutAuth, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + }), + findAttachments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + query: { + perPage: MAX_DOCS_PER_PAGE, + }, + }), + ]); + + expect(filesAfterDelete.total).to.be(0); + expect(attachmentsAfterDelete.comments.length).to.be(0); + }); + + it('should delete all files associated with multiple cases', async () => { + const [{ caseInfo: postedCase1 }, { caseInfo: postedCase2 }] = await Promise.all([ + createCaseWithFiles({ + supertest: supertestWithoutAuth, + fileKind: SECURITY_SOLUTION_FILE_KIND, + owner: 'securitySolution', + }), + createCaseWithFiles({ + supertest: supertestWithoutAuth, + fileKind: SECURITY_SOLUTION_FILE_KIND, + owner: 'securitySolution', + }), + ]); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase1.id, postedCase2.id], + }); + + const [filesAfterDelete, attachmentsAfterDelete, attachmentsAfterDelete2] = + await Promise.all([ + listFiles({ + supertest: supertestWithoutAuth, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + }), + findAttachments({ + supertest: supertestWithoutAuth, + caseId: postedCase1.id, + query: { + perPage: MAX_DOCS_PER_PAGE, + }, + }), + findAttachments({ + supertest: supertestWithoutAuth, + caseId: postedCase2.id, + query: { + perPage: MAX_DOCS_PER_PAGE, + }, + }), + ]); + + expect(filesAfterDelete.total).to.be(0); + expect(attachmentsAfterDelete.comments.length).to.be(0); + expect(attachmentsAfterDelete2.comments.length).to.be(0); + }); + }); + describe('rbac', () => { + describe('files', () => { + // we need api_int_users and roles because they have authorization for the actual plugins (not the fixtures). This + // is needed because the fixture plugins are not registered as file kinds + before(async () => { + await createUsersAndRoles(getService, api_int_users, api_int_roles); + }); + + after(async () => { + await deleteUsersAndRoles(getService, api_int_users, api_int_roles); + }); + + it('should delete a case when the user has access to delete the case and files', async () => { + const { caseInfo: postedCase } = await createCaseWithFiles({ + supertest: supertestWithoutAuth, + fileKind: SECURITY_SOLUTION_FILE_KIND, + owner: 'securitySolution', + auth: { user: secAllUser, space: 'space1' }, + }); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + auth: { user: secAllUser, space: 'space1' }, + }); + + const [filesAfterDelete, attachmentsAfterDelete] = await Promise.all([ + listFiles({ + supertest: supertestWithoutAuth, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + auth: { user: secAllUser, space: 'space1' }, + }), + findAttachments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + query: { + perPage: MAX_DOCS_PER_PAGE, + }, + auth: { user: secAllUser, space: 'space1' }, + }), + ]); + + expect(filesAfterDelete.total).to.be(0); + expect(attachmentsAfterDelete.comments.length).to.be(0); + }); + + it('should not delete a case when the user does not have access to the file kind of the files', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolution' }), + 200, + { user: secAllUser, space: 'space1' } + ); + const { create: createdFile } = await createAndUploadFile({ + supertest: supertestWithoutAuth, + createFileParams: { + name: 'testfile', + // use observability for the file kind which the security user should not have access to + kind: OBSERVABILITY_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + owner: [postedCase.owner], + }, + }, + data: 'abc', + auth: { user: superUser, space: 'space1' }, + }); + + await bulkCreateAttachments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: [ + getFilesAttachmentReq({ + externalReferenceId: createdFile.file.id, + owner: 'securitySolution', + }), + ], + auth: { user: secAllUser, space: 'space1' }, + }); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + auth: { user: secAllUser, space: 'space1' }, + expectedHttpCode: 403, + }); + + const [filesAfterDelete, attachmentsAfterDelete] = await Promise.all([ + listFiles({ + supertest: supertestWithoutAuth, + params: { + kind: OBSERVABILITY_FILE_KIND, + }, + auth: { user: superUser, space: 'space1' }, + }), + findAttachments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + query: { + perPage: MAX_DOCS_PER_PAGE, + }, + auth: { user: secAllUser, space: 'space1' }, + }), + ]); + + expect(filesAfterDelete.total).to.be(1); + expect(attachmentsAfterDelete.comments.length).to.be(1); + }); + }); + it('User: security solution only - should delete a case', async () => { const postedCase = await createCase( supertestWithoutAuth, @@ -255,3 +470,69 @@ export default ({ getService }: FtrProviderContext): void => { }); }); }; + +const createCaseWithFiles = async ({ + supertest, + fileKind, + owner, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest; + fileKind: string; + owner: string; + auth?: { user: User; space: string | null }; +}) => { + const postedCase = await createCase(supertest, getPostCaseRequest({ owner }), 200, auth); + + const files = await Promise.all([ + createAndUploadFile({ + supertest, + createFileParams: { + name: 'testfile', + kind: fileKind, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + owner: [postedCase.owner], + }, + }, + data: 'abc', + auth, + }), + createAndUploadFile({ + supertest, + createFileParams: { + name: 'testfile', + kind: fileKind, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + owner: [postedCase.owner], + }, + }, + data: 'abc', + auth, + }), + ]); + + const caseWithAttachments = await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [ + getFilesAttachmentReq({ + externalReferenceId: files[0].create.file.id, + owner, + }), + getFilesAttachmentReq({ + externalReferenceId: files[1].create.file.id, + owner, + }), + ], + auth, + }); + + return { + caseInfo: caseWithAttachments, + attachments: files, + }; +};