From 1e6351517045bfca452e5aa3872e42da8891628e Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Mon, 3 Apr 2023 12:39:31 -0400 Subject: [PATCH] [Cases] Delete file API (#153604) This PR adds a new API for deleting a file within a case given the file id. This API will retrieve the file saved object provided in the query and perform an authorization check using each file's file kind. It will also retrieve all the attachments associated with the files and perform an authorization check for each attachment. This api supports calling it with ids that only have the file saved objects and not the corresponding attachments. For the deletion sub privilege to work correctly, it must have access to updating the file saved objects. Therefore we also had to give the delete sub privilege all access to the file saved objects types. This PR does not contain the logic for deleting all files when a case is deleted. That'll be completed in a separate PR. Example request ``` POST /internal/cases/a58847c0-cccc-11ed-b071-4f11aa24310c/attachments/files/_bulk_delete { "ids": ["clfr5sdky0001n811gjot7tv5", "clfr5sgru0002n8112t54bave"] } ``` Example response ``` 204 ``` Notable changes - Refactored the delete all comments to leverage the bulk delete API from the saved object client - Updated the names of the `api_integration` users and roles to avoid clashing with the ones in `cases_api_integration` --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../cases/common/api/cases/comment/files.ts | 10 + x-pack/plugins/cases/common/api/helpers.ts | 5 + .../plugins/cases/common/constants/files.ts | 10 +- .../plugins/cases/common/constants/index.ts | 2 + .../plugins/cases/common/files/index.test.ts | 66 ++ x-pack/plugins/cases/common/files/index.ts | 43 + .../plugins/cases/common/schema/index.test.ts | 57 ++ x-pack/plugins/cases/common/schema/index.ts | 42 + x-pack/plugins/cases/common/utils/api_tags.ts | 7 +- x-pack/plugins/cases/public/files/index.ts | 3 +- .../server/client/attachments/bulk_delete.ts | 167 ++++ .../server/client/attachments/bulk_get.ts | 19 +- .../cases/server/client/attachments/client.ts | 20 +- .../cases/server/client/attachments/delete.ts | 24 +- .../cases/server/client/attachments/types.ts | 14 + x-pack/plugins/cases/server/client/factory.ts | 5 + x-pack/plugins/cases/server/client/mocks.ts | 5 +- x-pack/plugins/cases/server/client/types.ts | 2 + .../common/limiter_checker/limiters/files.ts | 3 +- .../cases/server/common/partitioning.test.ts | 113 +++ .../cases/server/common/partitioning.ts | 17 + .../cases/server/common/references.test.ts | 56 ++ .../plugins/cases/server/common/references.ts | 22 + x-pack/plugins/cases/server/common/types.ts | 3 + x-pack/plugins/cases/server/features.ts | 4 +- x-pack/plugins/cases/server/files/index.ts | 6 +- x-pack/plugins/cases/server/plugin.ts | 4 +- .../server/routes/api/get_internal_routes.ts | 2 + .../internal/bulk_delete_file_attachments.ts | 45 + .../server/services/attachments/index.ts | 21 +- .../services/attachments/operations/get.ts | 88 ++ x-pack/plugins/cases/server/services/mocks.ts | 3 +- .../user_actions/operations/create.ts | 5 + .../server/services/user_actions/transform.ts | 11 +- x-pack/plugins/observability/server/plugin.ts | 4 +- .../security_solution/server/features.ts | 4 +- .../apis/cases/common/roles.ts | 136 ++- .../apis/cases/common/users.ts | 84 +- .../test/api_integration/apis/cases/files.ts | 59 +- .../common/lib/api/attachments.ts | 22 + .../common/lib/api/files.ts | 114 ++- .../common/lib/authentication/roles.ts | 25 + .../common/lib/authentication/users.ts | 8 + .../common/lib/constants.ts | 17 + .../cases_api_integration/common/lib/mock.ts | 5 + .../common/plugins/cases/kibana.jsonc | 3 +- .../plugins/cases/server/files/index.ts | 43 + .../common/plugins/cases/server/plugin.ts | 4 + .../plugins/observability/server/plugin.ts | 8 +- .../security_solution/server/plugin.ts | 12 +- .../security_and_spaces/tests/common/index.ts | 1 + .../internal/bulk_delete_file_attachments.ts | 837 ++++++++++++++++++ .../security_and_spaces/tests/trial/index.ts | 1 + .../internal/bulk_delete_file_attachments.ts | 138 +++ 54 files changed, 2228 insertions(+), 201 deletions(-) create mode 100644 x-pack/plugins/cases/common/files/index.test.ts create mode 100644 x-pack/plugins/cases/common/files/index.ts create mode 100644 x-pack/plugins/cases/common/schema/index.test.ts create mode 100644 x-pack/plugins/cases/common/schema/index.ts create mode 100644 x-pack/plugins/cases/server/client/attachments/bulk_delete.ts create mode 100644 x-pack/plugins/cases/server/common/partitioning.test.ts create mode 100644 x-pack/plugins/cases/server/common/partitioning.ts create mode 100644 x-pack/plugins/cases/server/common/references.test.ts create mode 100644 x-pack/plugins/cases/server/common/references.ts create mode 100644 x-pack/plugins/cases/server/routes/api/internal/bulk_delete_file_attachments.ts create mode 100644 x-pack/test/cases_api_integration/common/lib/constants.ts create mode 100644 x-pack/test/cases_api_integration/common/plugins/cases/server/files/index.ts create mode 100644 x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_delete_file_attachments.ts create mode 100644 x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/bulk_delete_file_attachments.ts diff --git a/x-pack/plugins/cases/common/api/cases/comment/files.ts b/x-pack/plugins/cases/common/api/cases/comment/files.ts index 58fee11997c74..66555b1a584d9 100644 --- a/x-pack/plugins/cases/common/api/cases/comment/files.ts +++ b/x-pack/plugins/cases/common/api/cases/comment/files.ts @@ -6,6 +6,8 @@ */ import * as rt from 'io-ts'; +import { MAX_DELETE_FILES } from '../../../constants'; +import { limitedArraySchema, NonEmptyString } from '../../../schema'; export const FileAttachmentMetadataRt = rt.type({ files: rt.array( @@ -21,3 +23,11 @@ export const FileAttachmentMetadataRt = rt.type({ export type FileAttachmentMetadata = rt.TypeOf; export const FILE_ATTACHMENT_TYPE = '.files'; + +const MIN_DELETE_IDS = 1; + +export const BulkDeleteFileAttachmentsRequestRt = rt.type({ + ids: limitedArraySchema(NonEmptyString, MIN_DELETE_IDS, MAX_DELETE_FILES), +}); + +export type BulkDeleteFileAttachmentsRequest = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/helpers.ts b/x-pack/plugins/cases/common/api/helpers.ts index db5a333c7546e..95180c9695950 100644 --- a/x-pack/plugins/cases/common/api/helpers.ts +++ b/x-pack/plugins/cases/common/api/helpers.ts @@ -20,6 +20,7 @@ import { INTERNAL_BULK_GET_ATTACHMENTS_URL, INTERNAL_CONNECTORS_URL, INTERNAL_CASE_USERS_URL, + INTERNAL_DELETE_FILE_ATTACHMENTS_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -77,3 +78,7 @@ export const getCaseConnectorsUrl = (id: string): string => { export const getCaseUsersUrl = (id: string): string => { return INTERNAL_CASE_USERS_URL.replace('{case_id}', id); }; + +export const getCasesDeleteFileAttachmentsUrl = (id: string): string => { + return INTERNAL_DELETE_FILE_ATTACHMENTS_URL.replace('{case_id}', id); +}; diff --git a/x-pack/plugins/cases/common/constants/files.ts b/x-pack/plugins/cases/common/constants/files.ts index b953f213f5ab5..572b612e2efc2 100644 --- a/x-pack/plugins/cases/common/constants/files.ts +++ b/x-pack/plugins/cases/common/constants/files.ts @@ -5,12 +5,6 @@ * 2.0. */ -import type { HttpApiTagOperation, Owner } from './types'; - export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MiB - -export const constructFilesHttpOperationTag = (owner: Owner, operation: HttpApiTagOperation) => { - return `${owner}FilesCases${operation}`; -}; - -export const constructFileKindIdByOwner = (owner: Owner) => `${owner}FilesCases`; +export const MAX_FILES_PER_CASE = 100; +export const MAX_DELETE_FILES = 50; diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index f837223e6a07c..b0bf34eef6c29 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -78,6 +78,8 @@ export const INTERNAL_BULK_GET_CASES_URL = `${CASES_INTERNAL_URL}/_bulk_get` as export const INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL = `${CASES_INTERNAL_URL}/{case_id}/user_actions/_stats` as const; export const INTERNAL_CASE_USERS_URL = `${CASES_INTERNAL_URL}/{case_id}/_users` as const; +export const INTERNAL_DELETE_FILE_ATTACHMENTS_URL = + `${CASES_INTERNAL_URL}/{case_id}/attachments/files/_bulk_delete` as const; /** * Action routes diff --git a/x-pack/plugins/cases/common/files/index.test.ts b/x-pack/plugins/cases/common/files/index.test.ts new file mode 100644 index 0000000000000..6f8770b268f97 --- /dev/null +++ b/x-pack/plugins/cases/common/files/index.test.ts @@ -0,0 +1,66 @@ +/* + * 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 { + constructFileKindIdByOwner, + constructFilesHttpOperationTag, + constructOwnerFromFileKind, +} from '.'; +import { APP_ID, OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../constants'; +import { HttpApiTagOperation } from '../constants/types'; + +describe('files index', () => { + describe('constructFilesHttpOperationTag', () => { + it.each([ + [SECURITY_SOLUTION_OWNER, HttpApiTagOperation.Read, 'securitySolutionFilesCasesRead'], + [OBSERVABILITY_OWNER, HttpApiTagOperation.Create, 'observabilityFilesCasesCreate'], + [APP_ID, HttpApiTagOperation.Delete, 'casesFilesCasesDelete'], + ])('builds the tag for owner: %p operation: %p tag: %p', (owner, operation, tag) => { + expect(constructFilesHttpOperationTag(owner, operation)).toEqual(tag); + }); + }); + + describe('constructFileKindIdByOwner', () => { + it.each([ + [SECURITY_SOLUTION_OWNER, 'securitySolutionFilesCases'], + [OBSERVABILITY_OWNER, 'observabilityFilesCases'], + [APP_ID, 'casesFilesCases'], + ])('builds the right file kind for owner: %p file kind: %p', (owner, fileKind) => { + expect(constructFileKindIdByOwner(owner)).toEqual(fileKind); + }); + }); + + describe('constructOwnerFromFileKind', () => { + it('returns undefined when the delimiter cannot be found with an empty string', () => { + expect(constructOwnerFromFileKind('')).toBeUndefined(); + }); + + it('returns undefined when the delimiter cannot be found in a non-empty string', () => { + expect(constructOwnerFromFileKind('abc')).toBeUndefined(); + }); + + it('returns undefined when the extract owner is not part of the valid owners array', () => { + expect(constructOwnerFromFileKind('abcFilesCases')).toBeUndefined(); + }); + + it('returns undefined when there is a string after the delimiter', () => { + expect(constructOwnerFromFileKind('securitySolutionFilesCasesAbc')).toBeUndefined(); + }); + + it('returns securitySolution when given the security solution file kind', () => { + expect(constructOwnerFromFileKind('securitySolutionFilesCases')).toEqual('securitySolution'); + }); + + it('returns observability when given the observability file kind', () => { + expect(constructOwnerFromFileKind('observabilityFilesCases')).toEqual('observability'); + }); + + it('returns cases when given the cases file kind', () => { + expect(constructOwnerFromFileKind('casesFilesCases')).toEqual('cases'); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/files/index.ts b/x-pack/plugins/cases/common/files/index.ts new file mode 100644 index 0000000000000..6ab2268eeb0ce --- /dev/null +++ b/x-pack/plugins/cases/common/files/index.ts @@ -0,0 +1,43 @@ +/* + * 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 * as rt from 'io-ts'; +import { isEmpty } from 'lodash'; +import { OWNERS } from '../constants'; +import type { HttpApiTagOperation, Owner } from '../constants/types'; + +/** + * This type is only used to validate for deletion, it does not check all the fields that should exist in the file + * metadata. + */ +export const CaseFileMetadataForDeletionRt = rt.type({ + caseIds: rt.array(rt.string), +}); + +export type CaseFileMetadata = rt.TypeOf; + +const FILE_KIND_DELIMITER = 'FilesCases'; + +export const constructFilesHttpOperationTag = (owner: Owner, operation: HttpApiTagOperation) => { + return `${owner}${FILE_KIND_DELIMITER}${operation}`; +}; + +export const constructFileKindIdByOwner = (owner: Owner) => `${owner}${FILE_KIND_DELIMITER}`; + +export const constructOwnerFromFileKind = (fileKind: string): Owner | undefined => { + const splitString = fileKind.split(FILE_KIND_DELIMITER); + + if (splitString.length === 2 && isEmpty(splitString[1]) && isValidOwner(splitString[0])) { + return splitString[0]; + } +}; + +const isValidOwner = (ownerToValidate: string): ownerToValidate is Owner => { + const foundOwner = OWNERS.find((validOwner) => validOwner === ownerToValidate); + + return foundOwner !== undefined; +}; diff --git a/x-pack/plugins/cases/common/schema/index.test.ts b/x-pack/plugins/cases/common/schema/index.test.ts new file mode 100644 index 0000000000000..ee116e399045c --- /dev/null +++ b/x-pack/plugins/cases/common/schema/index.test.ts @@ -0,0 +1,57 @@ +/* + * 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 { PathReporter } from 'io-ts/lib/PathReporter'; + +import { limitedArraySchema, NonEmptyString } from '.'; + +describe('schema', () => { + it('fails when given an empty string', () => { + expect(PathReporter.report(limitedArraySchema(NonEmptyString, 1, 1).decode(['']))) + .toMatchInlineSnapshot(` + Array [ + "string must have length >= 1", + ] + `); + }); + + it('fails when given an empty array', () => { + expect(PathReporter.report(limitedArraySchema(NonEmptyString, 1, 1).decode([]))) + .toMatchInlineSnapshot(` + Array [ + "array must be of length >= 1", + ] + `); + }); + + it('fails when given an array larger than the limit of one item', () => { + expect(PathReporter.report(limitedArraySchema(NonEmptyString, 1, 1).decode(['a', 'b']))) + .toMatchInlineSnapshot(` + Array [ + "array must be of length <= 1", + ] + `); + }); + + it('succeeds when given an array of 1 item with a non-empty string', () => { + expect(PathReporter.report(limitedArraySchema(NonEmptyString, 1, 1).decode(['a']))) + .toMatchInlineSnapshot(` + Array [ + "No errors!", + ] + `); + }); + + it('succeeds when given an array of 0 item with a non-empty string when the min is 0', () => { + expect(PathReporter.report(limitedArraySchema(NonEmptyString, 0, 2).decode([]))) + .toMatchInlineSnapshot(` + Array [ + "No errors!", + ] + `); + }); +}); diff --git a/x-pack/plugins/cases/common/schema/index.ts b/x-pack/plugins/cases/common/schema/index.ts new file mode 100644 index 0000000000000..c28a7f8d6d840 --- /dev/null +++ b/x-pack/plugins/cases/common/schema/index.ts @@ -0,0 +1,42 @@ +/* + * 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 * as rt from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +export const NonEmptyString = new rt.Type( + 'NonEmptyString', + rt.string.is, + (input, context) => + either.chain(rt.string.validate(input, context), (s) => { + if (s.trim() !== '') { + return rt.success(s); + } else { + return rt.failure(input, context, 'string must have length >= 1'); + } + }), + rt.identity +); + +export const limitedArraySchema = (codec: T, min: number, max: number) => + new rt.Type>, Array>, unknown>( + 'LimitedArray', + (input): input is T[] => rt.array(codec).is(input), + (input, context) => + either.chain(rt.array(codec).validate(input, context), (s) => { + if (s.length < min) { + return rt.failure(input, context, `array must be of length >= ${min}`); + } + + if (s.length > max) { + return rt.failure(input, context, `array must be of length <= ${max}`); + } + + return rt.success(s); + }), + rt.identity + ); diff --git a/x-pack/plugins/cases/common/utils/api_tags.ts b/x-pack/plugins/cases/common/utils/api_tags.ts index 707188a0fba33..d9e3ad25a04c0 100644 --- a/x-pack/plugins/cases/common/utils/api_tags.ts +++ b/x-pack/plugins/cases/common/utils/api_tags.ts @@ -5,13 +5,10 @@ * 2.0. */ -import { - BULK_GET_USER_PROFILES_API_TAG, - constructFilesHttpOperationTag, - SUGGEST_USER_PROFILES_API_TAG, -} from '../constants'; +import { BULK_GET_USER_PROFILES_API_TAG, SUGGEST_USER_PROFILES_API_TAG } from '../constants'; import { HttpApiTagOperation } from '../constants/types'; import type { Owner } from '../constants/types'; +import { constructFilesHttpOperationTag } from '../files'; export const getApiTags = (owner: Owner) => { const create = constructFilesHttpOperationTag(owner, HttpApiTagOperation.Create); diff --git a/x-pack/plugins/cases/public/files/index.ts b/x-pack/plugins/cases/public/files/index.ts index 9da40d059cd2a..f9490323085af 100644 --- a/x-pack/plugins/cases/public/files/index.ts +++ b/x-pack/plugins/cases/public/files/index.ts @@ -8,9 +8,10 @@ import type { FilesSetup } from '@kbn/files-plugin/public'; import type { FileKindBrowser } from '@kbn/shared-ux-file-types'; import { ALLOWED_MIME_TYPES } from '../../common/constants/mime_types'; -import { constructFileKindIdByOwner, MAX_FILE_SIZE } from '../../common/constants'; +import { MAX_FILE_SIZE } from '../../common/constants'; import type { Owner } from '../../common/constants/types'; import { APP_ID, OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../common'; +import { constructFileKindIdByOwner } from '../../common/files'; const buildFileKind = (owner: Owner): FileKindBrowser => { return { diff --git a/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts b/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts new file mode 100644 index 0000000000000..c604c13b0970f --- /dev/null +++ b/x-pack/plugins/cases/server/client/attachments/bulk_delete.ts @@ -0,0 +1,167 @@ +/* + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +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 { 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 type { CasesClient } from '../client'; + +export const bulkDeleteFileAttachments = async ( + { caseId, fileIds }: BulkDeleteFileArgs, + clientArgs: CasesClientArgs, + casesClient: CasesClient +) => { + const { + user, + services: { attachmentService, userActionService }, + logger, + authorization, + fileService, + } = clientArgs; + + try { + const request = pipe( + excess(BulkDeleteFileAttachmentsRequestRt).decode({ ids: fileIds }), + fold(throwErrors(Boom.badRequest), identity) + ); + + await casesClient.cases.resolve({ id: caseId, includeComments: false }); + + const fileEntities = await getFileEntities(caseId, request.ids, fileService); + + // It's possible for this to return an empty array if there was an error creating file attachments in which case the + // file would be present but the case attachment would not + const fileAttachments = await attachmentService.getter.getFileAttachments({ + caseId, + fileIds: request.ids, + }); + + await authorization.ensureAuthorized({ + entities: [ + ...fileAttachments.map((attachment) => ({ + id: attachment.id, + owner: attachment.attributes.owner, + })), + ...fileEntities, + ], + operation: Operations.deleteComment, + }); + + await Promise.all([ + pMap(request.ids, async (fileId: string) => fileService.delete({ id: fileId }), { + concurrency: MAX_CONCURRENT_SEARCHES, + }), + attachmentService.bulkDelete({ + attachmentIds: fileAttachments.map((so) => so.id), + refresh: false, + }), + ]); + + await userActionService.creator.bulkCreateAttachmentDeletion({ + caseId, + attachments: fileAttachments.map((attachment) => ({ + id: attachment.id, + owner: attachment.attributes.owner, + attachment: attachment.attributes, + })), + user, + }); + } catch (error) { + let errorToTrack = error; + + // if it's an error from the file service let's put it in a boom so we don't loose the status code of a 404 + if (error instanceof FileNotFoundError) { + errorToTrack = Boom.notFound(error.message); + } + + throw createCaseError({ + message: `Failed to delete file attachments for case: ${caseId}: ${error}`, + error: errorToTrack, + logger, + }); + } +}; + +const getFileEntities = async ( + caseId: BulkDeleteFileArgs['caseId'], + fileIds: BulkDeleteFileArgs['fileIds'], + fileService: FileServiceStart +) => { + const files = await getFiles(caseId, fileIds, fileService); + + const fileEntities = createFileEntities(files); + + return fileEntities; +}; + +const getFiles = async ( + caseId: BulkDeleteFileArgs['caseId'], + fileIds: BulkDeleteFileArgs['fileIds'], + fileService: FileServiceStart +) => { + // 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 }), { + concurrency: MAX_CONCURRENT_SEARCHES, + }); + + const [validFiles, invalidFiles] = partition(files, (file) => { + return ( + CaseFileMetadataForDeletionRt.is(file.data.meta) && + file.data.meta.caseIds.length === 1 && + file.data.meta.caseIds.includes(caseId) + ); + }) as [File[], File[]]; + + if (invalidFiles.length > 0) { + const invalidIds = invalidFiles.map((fileInfo) => fileInfo.id); + + // I'm intentionally being vague here because it's possible an unauthorized user could attempt to delete files + throw Boom.badRequest(`Failed to delete files because filed ids were invalid: ${invalidIds}`); + } + + if (validFiles.length <= 0) { + 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; +}; diff --git a/x-pack/plugins/cases/server/client/attachments/bulk_get.ts b/x-pack/plugins/cases/server/client/attachments/bulk_get.ts index 640a16484f443..8970271224252 100644 --- a/x-pack/plugins/cases/server/client/attachments/bulk_get.ts +++ b/x-pack/plugins/cases/server/client/attachments/bulk_get.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { SavedObject, SavedObjectReference } from '@kbn/core/server'; import Boom from '@hapi/boom'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -12,7 +11,7 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { partition } from 'lodash'; -import { CASE_SAVED_OBJECT, MAX_BULK_GET_ATTACHMENTS } from '../../../common/constants'; +import { MAX_BULK_GET_ATTACHMENTS } from '../../../common/constants'; import type { BulkGetAttachmentsResponse, CommentAttributes } from '../../../common/api'; import { excess, @@ -26,13 +25,12 @@ import type { CasesClientArgs, SOWithErrors } from '../types'; import { Operations } from '../../authorization'; import type { BulkGetArgs } from './types'; import type { BulkOptionalAttributes, OptionalAttributes } from '../../services/attachments/types'; -import { CASE_REF_NAME } from '../../common/constants'; import type { CasesClient } from '../client'; +import type { AttachmentSavedObject } from '../../common/types'; +import { partitionByCaseAssociation } from '../../common/partitioning'; type AttachmentSavedObjectWithErrors = SOWithErrors; -type AttachmentSavedObject = SavedObject; - /** * Retrieves multiple attachments by id. */ @@ -126,17 +124,6 @@ const partitionBySOError = (attachments: Array attachment.error == null && attachment.attributes != null ) as [AttachmentSavedObject[], AttachmentSavedObjectWithErrors]; -const partitionByCaseAssociation = (caseId: string, attachments: AttachmentSavedObject[]) => - partition(attachments, (attachment) => { - const ref = getCaseReference(attachment.references); - - return caseId === ref?.id; - }); - -const getCaseReference = (references: SavedObjectReference[]): SavedObjectReference | undefined => { - return references.find((ref) => ref.name === CASE_REF_NAME && ref.type === CASE_SAVED_OBJECT); -}; - const constructErrors = ({ caseId, soBulkGetErrors, diff --git a/x-pack/plugins/cases/server/client/attachments/client.ts b/x-pack/plugins/cases/server/client/attachments/client.ts index 5647427f76cf4..5b204a21929c2 100644 --- a/x-pack/plugins/cases/server/client/attachments/client.ts +++ b/x-pack/plugins/cases/server/client/attachments/client.ts @@ -29,12 +29,14 @@ import type { GetArgs, UpdateArgs, BulkGetArgs, + BulkDeleteFileArgs, } from './types'; import { bulkCreate } from './bulk_create'; import { deleteAll, deleteComment } from './delete'; import { find, get, getAll, getAllAlertsAttachToCase } from './get'; import { bulkGet } from './bulk_get'; import { update } from './update'; +import { bulkDeleteFileAttachments } from './bulk_delete'; /** * API for interacting with the attachments to a case. @@ -54,6 +56,7 @@ export interface AttachmentsSubClient { * Deletes a single attachment for a specific case. */ delete(deleteArgs: DeleteArgs): Promise; + bulkDeleteFileAttachments(deleteArgs: BulkDeleteFileArgs): Promise; /** * Retrieves all comments matching the search criteria. */ @@ -92,14 +95,15 @@ export const createAttachmentsSubClient = ( add: (params: AddArgs) => addComment(params, clientArgs), bulkCreate: (params: BulkCreateArgs) => bulkCreate(params, clientArgs), bulkGet: (params) => bulkGet(params, clientArgs, casesClient), - deleteAll: (deleteAllArgs: DeleteAllArgs) => deleteAll(deleteAllArgs, clientArgs), - delete: (deleteArgs: DeleteArgs) => deleteComment(deleteArgs, clientArgs), - find: (findArgs: FindArgs) => find(findArgs, clientArgs), - getAllAlertsAttachToCase: (params: GetAllAlertsAttachToCase) => - getAllAlertsAttachToCase(params, clientArgs, casesClient), - getAll: (getAllArgs: GetAllArgs) => getAll(getAllArgs, clientArgs), - get: (getArgs: GetArgs) => get(getArgs, clientArgs), - update: (updateArgs: UpdateArgs) => update(updateArgs, clientArgs), + delete: (params) => deleteComment(params, clientArgs), + deleteAll: (params) => deleteAll(params, clientArgs), + bulkDeleteFileAttachments: (params) => + bulkDeleteFileAttachments(params, clientArgs, casesClient), + find: (params) => find(params, clientArgs), + getAllAlertsAttachToCase: (params) => getAllAlertsAttachToCase(params, clientArgs, casesClient), + getAll: (params) => getAll(params, clientArgs), + get: (params) => get(params, clientArgs), + update: (params) => update(params, clientArgs), }; return Object.freeze(attachmentSubClient); diff --git a/x-pack/plugins/cases/server/client/attachments/delete.ts b/x-pack/plugins/cases/server/client/attachments/delete.ts index 1a11620d9998e..50291f6a684c2 100644 --- a/x-pack/plugins/cases/server/client/attachments/delete.ts +++ b/x-pack/plugins/cases/server/client/attachments/delete.ts @@ -6,13 +6,11 @@ */ import Boom from '@hapi/boom'; -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 { CASE_SAVED_OBJECT } from '../../../common/constants'; 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'; import { Operations } from '../../authorization'; @@ -20,8 +18,6 @@ import type { DeleteAllArgs, DeleteArgs } from './types'; /** * Delete all comments for a case. - * - * @ignore */ export async function deleteAll( { caseID }: DeleteAllArgs, @@ -51,15 +47,9 @@ export async function deleteAll( })), }); - const mapper = async (comment: SavedObject) => - attachmentService.delete({ - attachmentId: comment.id, - refresh: false, - }); - - // Ensuring we don't too many concurrent deletions running. - await pMap(comments.saved_objects, mapper, { - concurrency: MAX_CONCURRENT_SEARCHES, + await attachmentService.bulkDelete({ + attachmentIds: comments.saved_objects.map((so) => so.id), + refresh: false, }); await userActionService.creator.bulkCreateAttachmentDeletion({ @@ -82,8 +72,6 @@ export async function deleteAll( /** * Deletes an attachment - * - * @ignore */ export async function deleteComment( { caseID, attachmentID }: DeleteArgs, @@ -118,8 +106,8 @@ export async function deleteComment( throw Boom.notFound(`This comment ${attachmentID} does not exist in ${id}.`); } - await attachmentService.delete({ - attachmentId: attachmentID, + await attachmentService.bulkDelete({ + attachmentIds: [attachmentID], refresh: false, }); diff --git a/x-pack/plugins/cases/server/client/attachments/types.ts b/x-pack/plugins/cases/server/client/attachments/types.ts index 51c1de4fe3877..ec54a10b88275 100644 --- a/x-pack/plugins/cases/server/client/attachments/types.ts +++ b/x-pack/plugins/cases/server/client/attachments/types.ts @@ -41,6 +41,20 @@ export interface DeleteAllArgs { caseID: string; } +/** + * Parameters for deleting a file attachment. + */ +export interface BulkDeleteFileArgs { + /** + * The id of the case + */ + caseId: string; + /** + * The ids of the file saved objects + */ + fileIds: string[]; +} + /** * Parameters for deleting a single attachment of a case. */ diff --git a/x-pack/plugins/cases/server/client/factory.ts b/x-pack/plugins/cases/server/client/factory.ts index 89970a3652963..137cf69e0763a 100644 --- a/x-pack/plugins/cases/server/client/factory.ts +++ b/x-pack/plugins/cases/server/client/factory.ts @@ -33,6 +33,7 @@ import type { import type { PublicMethodsOf } from '@kbn/utility-types'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; +import type { FilesStart } from '@kbn/files-plugin/server'; import { SAVED_OBJECT_TYPES } from '../../common/constants'; import { Authorization } from '../authorization/authorization'; import { @@ -66,6 +67,7 @@ interface CasesClientFactoryArgs { persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; publicBaseUrl?: IBasePath['publicBaseUrl']; + filesPluginStart: FilesStart; } /** @@ -142,6 +144,8 @@ export class CasesClientFactory { const userInfo = await this.getUserInfo(request); + const fileService = this.options.filesPluginStart.fileServiceFactory.asScoped(request); + return createCasesClient({ services, unsecuredSavedObjectsClient, @@ -157,6 +161,7 @@ export class CasesClientFactory { spaceId: this.options.spacesPluginStart?.spacesService.getSpaceId(request) ?? DEFAULT_SPACE_ID, savedObjectsSerializer, + fileService, }); } diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index b7e34a4ea2af5..91ebbab569fd1 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -9,6 +9,7 @@ import type { PublicContract, PublicMethodsOf } from '@kbn/utility-types'; import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import type { ISavedObjectsSerializer } from '@kbn/core-saved-objects-server'; +import { createFileServiceMock } from '@kbn/files-plugin/server/mocks'; import { securityMock } from '@kbn/security-plugin/server/mocks'; import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client.mock'; import { makeLensEmbeddableFactory } from '@kbn/lens-plugin/server/embeddable/make_lens_embeddable_factory'; @@ -77,8 +78,9 @@ const createAttachmentsSubClientMock = (): AttachmentsSubClientMock => { bulkGet: jest.fn(), add: jest.fn(), bulkCreate: jest.fn(), - deleteAll: jest.fn(), delete: jest.fn(), + deleteAll: jest.fn(), + bulkDeleteFileAttachments: jest.fn(), find: jest.fn(), getAll: jest.fn(), get: jest.fn(), @@ -186,6 +188,7 @@ export const createCasesClientMockArgs = () => { ) ), savedObjectsSerializer: createSavedObjectsSerializerMock(), + fileService: createFileServiceMock(), }; }; diff --git a/x-pack/plugins/cases/server/client/types.ts b/x-pack/plugins/cases/server/client/types.ts index e052bb7f58550..9519d3cb64522 100644 --- a/x-pack/plugins/cases/server/client/types.ts +++ b/x-pack/plugins/cases/server/client/types.ts @@ -14,6 +14,7 @@ import type { IBasePath } from '@kbn/core-http-browser'; import type { ISavedObjectsSerializer } from '@kbn/core-saved-objects-server'; import type { KueryNode } from '@kbn/es-query'; import type { SavedObjectError } from '@kbn/core-saved-objects-common'; +import type { FileServiceStart } from '@kbn/files-plugin/server'; import type { CasesFindRequest, User } from '../../common/api'; import type { Authorization } from '../authorization/authorization'; import type { @@ -57,6 +58,7 @@ export interface CasesClientArgs { readonly spaceId: string; readonly savedObjectsSerializer: ISavedObjectsSerializer; readonly publicBaseUrl?: IBasePath['publicBaseUrl']; + readonly fileService: FileServiceStart; } export type CasesFindQueryParams = Partial< diff --git a/x-pack/plugins/cases/server/common/limiter_checker/limiters/files.ts b/x-pack/plugins/cases/server/common/limiter_checker/limiters/files.ts index d89cd3f62367a..dac2e124b669a 100644 --- a/x-pack/plugins/cases/server/common/limiter_checker/limiters/files.ts +++ b/x-pack/plugins/cases/server/common/limiter_checker/limiters/files.ts @@ -8,10 +8,9 @@ import { buildFilter } from '../../../client/utils'; import { CommentType, FILE_ATTACHMENT_TYPE } from '../../../../common/api'; import type { CommentRequest } from '../../../../common/api'; -import { CASE_COMMENT_SAVED_OBJECT } from '../../../../common/constants'; +import { CASE_COMMENT_SAVED_OBJECT, MAX_FILES_PER_CASE } from '../../../../common/constants'; import { isFileAttachmentRequest } from '../../utils'; import { BaseLimiter } from '../base_limiter'; -import { MAX_FILES_PER_CASE } from '../../../files'; export class FileLimiter extends BaseLimiter { constructor() { diff --git a/x-pack/plugins/cases/server/common/partitioning.test.ts b/x-pack/plugins/cases/server/common/partitioning.test.ts new file mode 100644 index 0000000000000..768b2a9238986 --- /dev/null +++ b/x-pack/plugins/cases/server/common/partitioning.test.ts @@ -0,0 +1,113 @@ +/* + * 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 { CASE_SAVED_OBJECT } from '../../common/constants'; +import { CASE_REF_NAME } from './constants'; +import { partitionByCaseAssociation } from './partitioning'; +import type { AttachmentSavedObject } from './types'; + +describe('partitioning', () => { + describe('partitionByCaseAssociation', () => { + it('returns empty arrays when given an empty array', () => { + expect(partitionByCaseAssociation('', [])).toEqual([[], []]); + }); + + it('returns attachments in the second array when attachment has an empty references array', () => { + expect( + partitionByCaseAssociation('123', [ + { + references: [], + } as unknown as AttachmentSavedObject, + ]) + ).toMatchInlineSnapshot(` + Array [ + Array [], + Array [ + Object { + "references": Array [], + }, + ], + ] + `); + }); + + it('returns attachments in the second array when the case id reference does not match the id passed in', () => { + expect( + partitionByCaseAssociation('123', [ + { + references: [{ name: CASE_REF_NAME, type: CASE_SAVED_OBJECT, id: 'abc' }], + } as unknown as AttachmentSavedObject, + ]) + ).toMatchInlineSnapshot(` + Array [ + Array [], + Array [ + Object { + "references": Array [ + Object { + "id": "abc", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + ], + ] + `); + }); + + it('returns attachments in the second array when the attachment does not have a valid case reference', () => { + expect( + partitionByCaseAssociation('123', [ + { + references: [{ name: 'abc', type: CASE_SAVED_OBJECT, id: '123' }], + } as unknown as AttachmentSavedObject, + ]) + ).toMatchInlineSnapshot(` + Array [ + Array [], + Array [ + Object { + "references": Array [ + Object { + "id": "123", + "name": "abc", + "type": "cases", + }, + ], + }, + ], + ] + `); + }); + + it('returns attachments in the first array when the case id reference matches the id passed in', () => { + expect( + partitionByCaseAssociation('123', [ + { + references: [{ name: CASE_REF_NAME, type: CASE_SAVED_OBJECT, id: '123' }], + } as unknown as AttachmentSavedObject, + ]) + ).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "references": Array [ + Object { + "id": "123", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + ], + Array [], + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/common/partitioning.ts b/x-pack/plugins/cases/server/common/partitioning.ts new file mode 100644 index 0000000000000..bf057255a1c2f --- /dev/null +++ b/x-pack/plugins/cases/server/common/partitioning.ts @@ -0,0 +1,17 @@ +/* + * 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 { partition } from 'lodash'; +import { getCaseReferenceId } from './references'; +import type { AttachmentSavedObject } from './types'; + +export const partitionByCaseAssociation = (caseId: string, attachments: AttachmentSavedObject[]) => + partition(attachments, (attachment) => { + const caseRefId = getCaseReferenceId(attachment.references); + + return caseId === caseRefId; + }); diff --git a/x-pack/plugins/cases/server/common/references.test.ts b/x-pack/plugins/cases/server/common/references.test.ts new file mode 100644 index 0000000000000..1014ce075e5a7 --- /dev/null +++ b/x-pack/plugins/cases/server/common/references.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { CASE_SAVED_OBJECT } from '../../common/constants'; +import { CASE_REF_NAME } from './constants'; +import { findReferenceId, getCaseReferenceId } from './references'; + +describe('references', () => { + describe('findReferenceId', () => { + it('returns undefined when references is undefined', () => { + expect(findReferenceId('', '', undefined)).toBeUndefined(); + }); + + it('returns undefined when the references array is empty', () => { + expect(findReferenceId('', '', [])).toBeUndefined(); + }); + + it('returns undefined when the name does not match', () => { + expect(findReferenceId('abc', '123', [{ name: 'hi', type: '123', id: '1' }])).toBeUndefined(); + }); + + it('returns undefined when the type does not match', () => { + expect(findReferenceId('abc', '123', [{ name: 'abc', type: 'hi', id: '1' }])).toBeUndefined(); + }); + + it('returns the id when a reference matches', () => { + expect(findReferenceId('abc', '123', [{ name: 'abc', type: '123', id: '1' }])).toEqual('1'); + }); + }); + + describe('getCaseReferenceId', () => { + it('returns undefined when the references array is empty', () => { + expect(getCaseReferenceId([])).toBeUndefined(); + }); + + it('returns undefined when the name does not match', () => { + expect( + getCaseReferenceId([{ name: 'hi', type: CASE_SAVED_OBJECT, id: '1' }]) + ).toBeUndefined(); + }); + + it('returns undefined when the type does not match', () => { + expect(getCaseReferenceId([{ name: CASE_REF_NAME, type: 'abc', id: '1' }])).toBeUndefined(); + }); + + it('returns the id when a reference matches', () => { + expect( + getCaseReferenceId([{ name: CASE_REF_NAME, type: CASE_SAVED_OBJECT, id: '1' }]) + ).toEqual('1'); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/common/references.ts b/x-pack/plugins/cases/server/common/references.ts new file mode 100644 index 0000000000000..033f5f33e0699 --- /dev/null +++ b/x-pack/plugins/cases/server/common/references.ts @@ -0,0 +1,22 @@ +/* + * 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 { SavedObjectReference } from '@kbn/core-saved-objects-api-server'; +import { CASE_SAVED_OBJECT } from '../../common/constants'; +import { CASE_REF_NAME } from './constants'; + +export const getCaseReferenceId = (references: SavedObjectReference[]): string | undefined => { + return findReferenceId(CASE_REF_NAME, CASE_SAVED_OBJECT, references); +}; + +export const findReferenceId = ( + name: string, + type: string, + references?: SavedObjectReference[] +): string | undefined => { + return references?.find((ref) => ref.name === name && ref.type === type)?.id; +}; diff --git a/x-pack/plugins/cases/server/common/types.ts b/x-pack/plugins/cases/server/common/types.ts index 8b43cc77480c5..0f4d64f60dfa6 100644 --- a/x-pack/plugins/cases/server/common/types.ts +++ b/x-pack/plugins/cases/server/common/types.ts @@ -9,6 +9,7 @@ import type { SavedObject } from '@kbn/core-saved-objects-server'; import type { KueryNode } from '@kbn/es-query'; import type { CaseAttributes, + CommentAttributes, CommentRequestExternalReferenceSOType, FileAttachmentMetadata, SavedObjectFindOptions, @@ -34,3 +35,5 @@ export type FileAttachmentRequest = Omit< > & { externalReferenceMetadata: FileAttachmentMetadata; }; + +export type AttachmentSavedObject = SavedObject; diff --git a/x-pack/plugins/cases/server/features.ts b/x-pack/plugins/cases/server/features.ts index 05ee00cb1b037..b44c3589ecd08 100644 --- a/x-pack/plugins/cases/server/features.ts +++ b/x-pack/plugins/cases/server/features.ts @@ -88,8 +88,8 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { }), includeIn: 'all', savedObject: { - all: [], - read: [], + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], }, cases: { delete: [APP_ID], diff --git a/x-pack/plugins/cases/server/files/index.ts b/x-pack/plugins/cases/server/files/index.ts index 464a977b27280..3c0b35c193f25 100644 --- a/x-pack/plugins/cases/server/files/index.ts +++ b/x-pack/plugins/cases/server/files/index.ts @@ -9,8 +9,6 @@ import type { FileJSON, FileKind } from '@kbn/files-plugin/common'; import type { FilesSetup } from '@kbn/files-plugin/server'; import { APP_ID, - constructFileKindIdByOwner, - constructFilesHttpOperationTag, MAX_FILE_SIZE, OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER, @@ -18,6 +16,7 @@ import { import type { Owner } from '../../common/constants/types'; import { HttpApiTagOperation } from '../../common/constants/types'; import { ALLOWED_MIME_TYPES, IMAGE_MIME_TYPES } from '../../common/constants/mime_types'; +import { constructFileKindIdByOwner, constructFilesHttpOperationTag } from '../../common/files'; const buildFileKind = (owner: Owner): FileKind => { return { @@ -31,7 +30,6 @@ const buildFileKind = (owner: Owner): FileKind => { const fileKindHttpTags = (owner: Owner): FileKind['http'] => { return { create: buildTag(owner, HttpApiTagOperation.Create), - delete: buildTag(owner, HttpApiTagOperation.Delete), download: buildTag(owner, HttpApiTagOperation.Read), getById: buildTag(owner, HttpApiTagOperation.Read), list: buildTag(owner, HttpApiTagOperation.Read), @@ -70,5 +68,3 @@ export const registerCaseFileKinds = (filesSetupPlugin: FilesSetup) => { filesSetupPlugin.registerFileKind(fileKind); } }; - -export const MAX_FILES_PER_CASE = 100; diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 4655748faeed3..21eb63bde3ff9 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -14,7 +14,7 @@ import type { CoreStart, } from '@kbn/core/server'; -import type { FilesSetup } from '@kbn/files-plugin/server'; +import type { FilesSetup, FilesStart } from '@kbn/files-plugin/server'; import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; import type { PluginSetupContract as ActionsPluginSetup, @@ -74,6 +74,7 @@ export interface PluginsSetup { export interface PluginsStart { actions: ActionsPluginStart; features: FeaturesPluginStart; + files: FilesStart; licensing: LicensingPluginStart; taskManager?: TaskManagerStartContract; security: SecurityPluginStart; @@ -212,6 +213,7 @@ export class CasePlugin { publicBaseUrl: core.http.basePath.publicBaseUrl, notifications: plugins.notifications, ruleRegistry: plugins.ruleRegistry, + filesPluginStart: plugins.files, }); const client = core.elasticsearch.client; diff --git a/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts b/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts index fd7e08f222c9a..364c3fc724f01 100644 --- a/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts +++ b/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts @@ -14,6 +14,7 @@ import { suggestUserProfilesRoute } from './internal/suggest_user_profiles'; import type { CaseRoute } from './types'; import { bulkGetAttachmentsRoute } from './internal/bulk_get_attachments'; import { getCaseUsersRoute } from './internal/get_case_users'; +import { bulkDeleteFileAttachments } from './internal/bulk_delete_file_attachments'; export const getInternalRoutes = (userProfileService: UserProfileService) => [ @@ -24,4 +25,5 @@ export const getInternalRoutes = (userProfileService: UserProfileService) => getCaseUserActionStatsRoute, bulkGetAttachmentsRoute, getCaseUsersRoute, + bulkDeleteFileAttachments, ] as CaseRoute[]; diff --git a/x-pack/plugins/cases/server/routes/api/internal/bulk_delete_file_attachments.ts b/x-pack/plugins/cases/server/routes/api/internal/bulk_delete_file_attachments.ts new file mode 100644 index 0000000000000..a5c6844159968 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/internal/bulk_delete_file_attachments.ts @@ -0,0 +1,45 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { INTERNAL_DELETE_FILE_ATTACHMENTS_URL } from '../../../../common/constants'; +import { createCasesRoute } from '../create_cases_route'; +import { createCaseError } from '../../../common/error'; +import { escapeHatch } from '../utils'; +import type { BulkDeleteFileAttachmentsRequest } from '../../../../common/api'; + +export const bulkDeleteFileAttachments = createCasesRoute({ + method: 'post', + path: INTERNAL_DELETE_FILE_ATTACHMENTS_URL, + params: { + params: schema.object({ + case_id: schema.string(), + }), + body: escapeHatch, + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const client = await caseContext.getCasesClient(); + + const requestBody = request.body as BulkDeleteFileAttachmentsRequest; + + await client.attachments.bulkDeleteFileAttachments({ + caseId: request.params.case_id, + fileIds: requestBody.ids, + }); + + return response.noContent(); + } catch (error) { + throw createCaseError({ + message: `Failed to delete files in route case id: ${request.params.case_id}: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/plugins/cases/server/services/attachments/index.ts b/x-pack/plugins/cases/server/services/attachments/index.ts index 865a28d763e6c..bde31a375ec72 100644 --- a/x-pack/plugins/cases/server/services/attachments/index.ts +++ b/x-pack/plugins/cases/server/services/attachments/index.ts @@ -33,7 +33,7 @@ import { } from '../so_references'; import type { SavedObjectFindOptionsKueryNode } from '../../common/types'; import type { IndexRefresh } from '../types'; -import type { AttachedToCaseArgs, GetAttachmentArgs, ServiceContext } from './types'; +import type { AttachedToCaseArgs, ServiceContext } from './types'; import { AttachmentGetter } from './operations/get'; type AlertsAttachedToCaseArgs = AttachedToCaseArgs; @@ -47,7 +47,9 @@ interface CountActionsAttachedToCaseArgs extends AttachedToCaseArgs { aggregations: Record; } -interface DeleteAttachmentArgs extends GetAttachmentArgs, IndexRefresh {} +interface DeleteAttachmentArgs extends IndexRefresh { + attachmentIds: string[]; +} interface CreateAttachmentArgs extends IndexRefresh { attributes: AttachmentAttributes; @@ -169,18 +171,21 @@ export class AttachmentService { } } - public async delete({ attachmentId, refresh }: DeleteAttachmentArgs) { + public async bulkDelete({ attachmentIds, refresh }: DeleteAttachmentArgs) { try { - this.context.log.debug(`Attempting to DELETE attachment ${attachmentId}`); - return await this.context.unsecuredSavedObjectsClient.delete( - CASE_COMMENT_SAVED_OBJECT, - attachmentId, + if (attachmentIds.length <= 0) { + return; + } + + this.context.log.debug(`Attempting to DELETE attachments ${attachmentIds}`); + return await this.context.unsecuredSavedObjectsClient.bulkDelete( + attachmentIds.map((id) => ({ id, type: CASE_COMMENT_SAVED_OBJECT })), { refresh, } ); } catch (error) { - this.context.log.error(`Error on DELETE attachment ${attachmentId}: ${error}`); + this.context.log.error(`Error on DELETE attachments ${attachmentIds}: ${error}`); throw error; } } diff --git a/x-pack/plugins/cases/server/services/attachments/operations/get.ts b/x-pack/plugins/cases/server/services/attachments/operations/get.ts index 89fa8bbb12277..b673039d33af1 100644 --- a/x-pack/plugins/cases/server/services/attachments/operations/get.ts +++ b/x-pack/plugins/cases/server/services/attachments/operations/get.ts @@ -7,6 +7,7 @@ import type { SavedObject } from '@kbn/core/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { FILE_SO_TYPE } from '@kbn/files-plugin/common'; import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, @@ -31,6 +32,9 @@ import { injectAttachmentAttributesAndHandleErrors, injectAttachmentSOAttributesFromRefs, } from '../../so_references'; +import { partitionByCaseAssociation } from '../../../common/partitioning'; +import type { AttachmentSavedObject } from '../../../common/types'; +import { getCaseReferenceId } from '../../../common/references'; type GetAllAlertsAttachToCaseArgs = AttachedToCaseArgs; @@ -247,4 +251,88 @@ export class AttachmentGetter { }, }; } + + public async getFileAttachments({ + caseId, + fileIds, + }: { + caseId: string; + fileIds: string[]; + }): Promise>> { + try { + this.context.log.debug('Attempting to find file attachments'); + + /** + * This is making a big assumption that a single file service saved object can only be associated within a single + * case. If a single file can be attached to multiple cases it will complicate deleting a file. + * + * The file's metadata would have to contain all case ids and deleting a file would need to removing a case id from + * array instead of deleting the entire saved object in the situation where the file is attached to multiple cases. + */ + const references = fileIds.map((id) => ({ id, type: FILE_SO_TYPE })); + + /** + * In the event that we add the ability to attach a file to a case that has already been uploaded we'll run into a + * scenario where a single file id could be associated with multiple case attachments. So we need + * to retrieve them all. + */ + const finder = + this.context.unsecuredSavedObjectsClient.createPointInTimeFinder( + { + type: CASE_COMMENT_SAVED_OBJECT, + hasReference: references, + sortField: 'created_at', + sortOrder: 'asc', + perPage: MAX_DOCS_PER_PAGE, + } + ); + + const foundAttachments: Array> = []; + + for await (const attachmentSavedObjects of finder.find()) { + foundAttachments.push( + ...attachmentSavedObjects.saved_objects.map((attachment) => { + const modifiedAttachment = injectAttachmentSOAttributesFromRefs( + attachment, + this.context.persistableStateAttachmentTypeRegistry + ); + + return modifiedAttachment; + }) + ); + } + + const [validFileAttachments, invalidFileAttachments] = partitionByCaseAssociation( + caseId, + foundAttachments + ); + + this.logInvalidFileAssociations(invalidFileAttachments, fileIds, caseId); + + return validFileAttachments; + } catch (error) { + this.context.log.error(`Error retrieving file attachments file ids: ${fileIds}: ${error}`); + throw error; + } + } + + private logInvalidFileAssociations( + attachments: AttachmentSavedObject[], + fileIds: string[], + targetCaseId: string + ) { + const caseIds: string[] = []; + for (const attachment of attachments) { + const caseRefId = getCaseReferenceId(attachment.references); + if (caseRefId != null) { + caseIds.push(caseRefId); + } + } + + if (caseIds.length > 0) { + this.context.log.warn( + `Found files associated to cases outside of request: ${caseIds} file ids: ${fileIds} target case id: ${targetCaseId}` + ); + } + } } diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index 5480d79a2c8d6..5ebec19f034a0 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -153,6 +153,7 @@ const createAttachmentGetterServiceMock = (): AttachmentGetterServiceMock => { getAllAlertsAttachToCase: jest.fn(), getCaseCommentStats: jest.fn(), getAttachmentIdsForCases: jest.fn(), + getFileAttachments: jest.fn(), }; return service as unknown as AttachmentGetterServiceMock; @@ -163,7 +164,7 @@ type FakeAttachmentService = PublicMethodsOf & AttachmentServ export const createAttachmentServiceMock = (): AttachmentServiceMock => { const service: FakeAttachmentService = { getter: createAttachmentGetterServiceMock(), - delete: jest.fn(), + bulkDelete: jest.fn(), create: jest.fn(), bulkCreate: jest.fn(), update: jest.fn(), diff --git a/x-pack/plugins/cases/server/services/user_actions/operations/create.ts b/x-pack/plugins/cases/server/services/user_actions/operations/create.ts index 9160445ec2474..ff58f9e6a52fa 100644 --- a/x-pack/plugins/cases/server/services/user_actions/operations/create.ts +++ b/x-pack/plugins/cases/server/services/user_actions/operations/create.ts @@ -288,6 +288,11 @@ export class UserActionPersister { refresh, }: BulkCreateAttachmentUserAction): Promise { this.context.log.debug(`Attempting to create a bulk create case user action`); + + if (attachments.length <= 0) { + return; + } + const userActions = attachments.reduce((acc, attachment) => { const userActionBuilder = this.builderFactory.getBuilder(ActionTypes.comment); const commentUserAction = userActionBuilder?.build({ diff --git a/x-pack/plugins/cases/server/services/user_actions/transform.ts b/x-pack/plugins/cases/server/services/user_actions/transform.ts index 9d7225660fbe4..439e57eed386d 100644 --- a/x-pack/plugins/cases/server/services/user_actions/transform.ts +++ b/x-pack/plugins/cases/server/services/user_actions/transform.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { SavedObject, SavedObjectReference, SavedObjectsFindResponse } from '@kbn/core/server'; +import type { SavedObject, SavedObjectsFindResponse } from '@kbn/core/server'; import { isCommentRequestTypePersistableState } from '../../../common/utils/attachments'; import { @@ -33,6 +33,7 @@ import { findConnectorIdReference } from '../transform'; import { isCommentRequestTypeExternalReferenceSO } from '../../common/utils'; import type { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry'; import { injectPersistableReferencesToSO } from '../../attachment_framework/so_references'; +import { findReferenceId } from '../../common/references'; export function transformFindResponseToExternalModel( userActions: SavedObjectsFindResponse, @@ -190,11 +191,3 @@ function getConnectorIdFromReferences( return null; } - -function findReferenceId( - name: string, - type: string, - references: SavedObjectReference[] -): string | undefined { - return references.find((ref) => ref.name === name && ref.type === type)?.id; -} diff --git a/x-pack/plugins/observability/server/plugin.ts b/x-pack/plugins/observability/server/plugin.ts index d59b15ad62440..2e79509fbef4a 100644 --- a/x-pack/plugins/observability/server/plugin.ts +++ b/x-pack/plugins/observability/server/plugin.ts @@ -134,8 +134,8 @@ export class ObservabilityPlugin implements Plugin { ), includeIn: 'all', savedObject: { - all: [], - read: [], + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], }, cases: { delete: [observabilityFeatureId], diff --git a/x-pack/plugins/security_solution/server/features.ts b/x-pack/plugins/security_solution/server/features.ts index 5f838f76d3bf5..0f887de6cfc49 100644 --- a/x-pack/plugins/security_solution/server/features.ts +++ b/x-pack/plugins/security_solution/server/features.ts @@ -86,8 +86,8 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { ), includeIn: 'all', savedObject: { - all: [], - read: [], + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], }, cases: { delete: [APP_ID], diff --git a/x-pack/test/api_integration/apis/cases/common/roles.ts b/x-pack/test/api_integration/apis/cases/common/roles.ts index 35ae20fcd2266..5c3e7025900fd 100644 --- a/x-pack/test/api_integration/apis/cases/common/roles.ts +++ b/x-pack/test/api_integration/apis/cases/common/roles.ts @@ -12,7 +12,7 @@ import { Role } from '../../../../cases_api_integration/common/lib/authenticatio */ export const secAllCasesOnlyDelete: Role = { - name: 'sec_all_cases_only_delete', + name: 'sec_all_cases_only_delete_api_int', privileges: { elasticsearch: { indices: [ @@ -36,8 +36,33 @@ export const secAllCasesOnlyDelete: Role = { }, }; +export const secAllCasesOnlyReadDelete: Role = { + name: 'sec_all_cases_only_read_delete_api_int', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + siem: ['all'], + securitySolutionCases: ['read', 'cases_delete'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + export const secAllCasesNoDelete: Role = { - name: 'sec_all_cases_no_delete', + name: 'sec_all_cases_no_delete_api_int', privileges: { elasticsearch: { indices: [ @@ -62,7 +87,7 @@ export const secAllCasesNoDelete: Role = { }; export const secAll: Role = { - name: 'sec_all_role', + name: 'sec_all_role_api_int', privileges: { elasticsearch: { indices: [ @@ -86,8 +111,33 @@ export const secAll: Role = { }, }; +export const secAllSpace1: Role = { + name: 'sec_all_role_space1_api_int', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + siem: ['all'], + securitySolutionCases: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + export const secAllCasesRead: Role = { - name: 'sec_all_cases_read_role', + name: 'sec_all_cases_read_role_api_int', privileges: { elasticsearch: { indices: [ @@ -112,7 +162,7 @@ export const secAllCasesRead: Role = { }; export const secAllCasesNone: Role = { - name: 'sec_all_cases_none_role', + name: 'sec_all_cases_none_role_api_int', privileges: { elasticsearch: { indices: [ @@ -136,7 +186,7 @@ export const secAllCasesNone: Role = { }; export const secReadCasesAll: Role = { - name: 'sec_read_cases_all_role', + name: 'sec_read_cases_all_role_api_int', privileges: { elasticsearch: { indices: [ @@ -161,7 +211,7 @@ export const secReadCasesAll: Role = { }; export const secReadCasesRead: Role = { - name: 'sec_read_cases_read_role', + name: 'sec_read_cases_read_role_api_int', privileges: { elasticsearch: { indices: [ @@ -186,7 +236,7 @@ export const secReadCasesRead: Role = { }; export const secRead: Role = { - name: 'sec_read_role', + name: 'sec_read_role_api_int', privileges: { elasticsearch: { indices: [ @@ -211,7 +261,7 @@ export const secRead: Role = { }; export const secReadCasesNone: Role = { - name: 'sec_read_cases_none_role', + name: 'sec_read_cases_none_role_api_int', privileges: { elasticsearch: { indices: [ @@ -239,7 +289,7 @@ export const secReadCasesNone: Role = { */ export const casesOnlyDelete: Role = { - name: 'cases_only_delete', + name: 'cases_only_delete_api_int', privileges: { elasticsearch: { indices: [ @@ -262,8 +312,32 @@ export const casesOnlyDelete: Role = { }, }; +export const casesOnlyReadDelete: Role = { + name: 'cases_only_read_delete_api_int', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + generalCases: ['read', 'cases_delete'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + export const casesNoDelete: Role = { - name: 'cases_no_delete', + name: 'cases_no_delete_api_int', privileges: { elasticsearch: { indices: [ @@ -287,7 +361,7 @@ export const casesNoDelete: Role = { }; export const casesAll: Role = { - name: 'cases_all_role', + name: 'cases_all_role_api_int', privileges: { elasticsearch: { indices: [ @@ -311,7 +385,7 @@ export const casesAll: Role = { }; export const casesRead: Role = { - name: 'cases_read_role', + name: 'cases_read_role_api_int', privileges: { elasticsearch: { indices: [ @@ -339,7 +413,7 @@ export const casesRead: Role = { */ export const obsCasesOnlyDelete: Role = { - name: 'obs_cases_only_delete', + name: 'obs_cases_only_delete_api_int', privileges: { elasticsearch: { indices: [ @@ -362,8 +436,32 @@ export const obsCasesOnlyDelete: Role = { }, }; +export const obsCasesOnlyReadDelete: Role = { + name: 'obs_cases_only_read_delete_api_int', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + observabilityCases: ['read', 'cases_delete'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + export const obsCasesNoDelete: Role = { - name: 'obs_cases_no_delete', + name: 'obs_cases_no_delete_api_int', privileges: { elasticsearch: { indices: [ @@ -387,7 +485,7 @@ export const obsCasesNoDelete: Role = { }; export const obsCasesAll: Role = { - name: 'obs_cases_all_role', + name: 'obs_cases_all_role_api_int', privileges: { elasticsearch: { indices: [ @@ -411,7 +509,7 @@ export const obsCasesAll: Role = { }; export const obsCasesRead: Role = { - name: 'obs_cases_read_role', + name: 'obs_cases_read_role_api_int', privileges: { elasticsearch: { indices: [ @@ -436,8 +534,10 @@ export const obsCasesRead: Role = { export const roles = [ secAllCasesOnlyDelete, + secAllCasesOnlyReadDelete, secAllCasesNoDelete, secAll, + secAllSpace1, secAllCasesRead, secAllCasesNone, secReadCasesAll, @@ -445,10 +545,12 @@ export const roles = [ secReadCasesNone, secRead, casesOnlyDelete, + casesOnlyReadDelete, casesNoDelete, casesAll, casesRead, obsCasesOnlyDelete, + obsCasesOnlyReadDelete, obsCasesNoDelete, obsCasesAll, obsCasesRead, diff --git a/x-pack/test/api_integration/apis/cases/common/users.ts b/x-pack/test/api_integration/apis/cases/common/users.ts index f37b13b98f31a..6cf938dcb0740 100644 --- a/x-pack/test/api_integration/apis/cases/common/users.ts +++ b/x-pack/test/api_integration/apis/cases/common/users.ts @@ -10,16 +10,20 @@ import { casesAll, casesNoDelete, casesOnlyDelete, + casesOnlyReadDelete, casesRead, obsCasesAll, obsCasesNoDelete, obsCasesOnlyDelete, + obsCasesOnlyReadDelete, obsCasesRead, secAll, secAllCasesNoDelete, secAllCasesNone, secAllCasesOnlyDelete, + secAllCasesOnlyReadDelete, secAllCasesRead, + secAllSpace1, secRead, secReadCasesAll, secReadCasesNone, @@ -31,55 +35,67 @@ import { */ export const secAllCasesOnlyDeleteUser: User = { - username: 'sec_all_cases_only_delete_user', + username: 'sec_all_cases_only_delete_user_api_int', password: 'password', roles: [secAllCasesOnlyDelete.name], }; +export const secAllCasesOnlyReadDeleteUser: User = { + username: 'sec_all_cases_only_read_delete_user_api_int', + password: 'password', + roles: [secAllCasesOnlyReadDelete.name], +}; + export const secAllCasesNoDeleteUser: User = { - username: 'sec_all_cases_no_delete_user', + username: 'sec_all_cases_no_delete_user_api_int', password: 'password', roles: [secAllCasesNoDelete.name], }; export const secAllUser: User = { - username: 'sec_all_user', + username: 'sec_all_user_api_int', password: 'password', roles: [secAll.name], }; +export const secAllSpace1User: User = { + username: 'sec_all_space1_user_api_int', + password: 'password', + roles: [secAllSpace1.name], +}; + export const secAllCasesReadUser: User = { - username: 'sec_all_cases_read_user', + username: 'sec_all_cases_read_user_api_int', password: 'password', roles: [secAllCasesRead.name], }; export const secAllCasesNoneUser: User = { - username: 'sec_all_cases_none_user', + username: 'sec_all_cases_none_user_api_int', password: 'password', roles: [secAllCasesNone.name], }; export const secReadCasesAllUser: User = { - username: 'sec_read_cases_all_user', + username: 'sec_read_cases_all_user_api_int', password: 'password', roles: [secReadCasesAll.name], }; export const secReadCasesReadUser: User = { - username: 'sec_read_cases_read_user', + username: 'sec_read_cases_read_user_api_int', password: 'password', roles: [secReadCasesRead.name], }; export const secReadUser: User = { - username: 'sec_read_user', + username: 'sec_read_user_api_int', password: 'password', roles: [secRead.name], }; export const secReadCasesNoneUser: User = { - username: 'sec_read_cases_none_user', + username: 'sec_read_cases_none_user_api_int', password: 'password', roles: [secReadCasesNone.name], }; @@ -89,25 +105,31 @@ export const secReadCasesNoneUser: User = { */ export const casesOnlyDeleteUser: User = { - username: 'cases_only_delete_user', + username: 'cases_only_delete_user_api_int', password: 'password', roles: [casesOnlyDelete.name], }; +export const casesOnlyReadDeleteUser: User = { + username: 'cases_only_read_delete_user_api_int', + password: 'password', + roles: [casesOnlyReadDelete.name], +}; + export const casesNoDeleteUser: User = { - username: 'cases_no_delete_user', + username: 'cases_no_delete_user_api_int', password: 'password', roles: [casesNoDelete.name], }; export const casesAllUser: User = { - username: 'cases_all_user', + username: 'cases_all_user_api_int', password: 'password', roles: [casesAll.name], }; export const casesReadUser: User = { - username: 'cases_read_user', + username: 'cases_read_user_api_int', password: 'password', roles: [casesRead.name], }; @@ -117,33 +139,57 @@ export const casesReadUser: User = { */ export const obsCasesOnlyDeleteUser: User = { - username: 'obs_cases_only_delete_user', + username: 'obs_cases_only_delete_user_api_int', password: 'password', roles: [obsCasesOnlyDelete.name], }; +export const obsCasesOnlyReadDeleteUser: User = { + username: 'obs_cases_only_read_delete_user_api_int', + password: 'password', + roles: [obsCasesOnlyReadDelete.name], +}; + export const obsCasesNoDeleteUser: User = { - username: 'obs_cases_no_delete_user', + username: 'obs_cases_no_delete_user_api_int', password: 'password', roles: [obsCasesNoDelete.name], }; export const obsCasesAllUser: User = { - username: 'obs_cases_all_user', + username: 'obs_cases_all_user_api_int', password: 'password', roles: [obsCasesAll.name], }; export const obsCasesReadUser: User = { - username: 'obs_cases_read_user', + username: 'obs_cases_read_user_api_int', password: 'password', roles: [obsCasesRead.name], }; +/** + * Users for Observability and Security Solution + */ + +export const obsSecCasesAllUser: User = { + username: 'obs_sec_cases_all_user_api_int', + password: 'password', + roles: [obsCasesAll.name, secAll.name], +}; + +export const obsSecCasesReadUser: User = { + username: 'obs_sec_cases_read_user_api_int', + password: 'password', + roles: [obsCasesRead.name, secRead.name], +}; + export const users = [ secAllCasesOnlyDeleteUser, + secAllCasesOnlyReadDeleteUser, secAllCasesNoDeleteUser, secAllUser, + secAllSpace1User, secAllCasesReadUser, secAllCasesNoneUser, secReadCasesAllUser, @@ -151,11 +197,15 @@ export const users = [ secReadUser, secReadCasesNoneUser, casesOnlyDeleteUser, + casesOnlyReadDeleteUser, casesNoDeleteUser, casesAllUser, casesReadUser, obsCasesOnlyDeleteUser, + obsCasesOnlyReadDeleteUser, obsCasesNoDeleteUser, obsCasesAllUser, obsCasesReadUser, + obsSecCasesAllUser, + obsSecCasesReadUser, ]; diff --git a/x-pack/test/api_integration/apis/cases/files.ts b/x-pack/test/api_integration/apis/cases/files.ts index 7431ac22ea5ed..236408390c2f0 100644 --- a/x-pack/test/api_integration/apis/cases/files.ts +++ b/x-pack/test/api_integration/apis/cases/files.ts @@ -7,46 +7,38 @@ import expect from '@kbn/expect'; -import { - APP_ID as CASES_APP_ID, - constructFileKindIdByOwner, -} from '@kbn/cases-plugin/common/constants'; -import { APP_ID as SECURITY_SOLUTION_APP_ID } from '@kbn/security-solution-plugin/common/constants'; -import { observabilityFeatureId as OBSERVABILITY_APP_ID } from '@kbn/observability-plugin/common'; import { BaseFilesClient } from '@kbn/shared-ux-file-types'; import { User } from '../../../cases_api_integration/common/lib/authentication/types'; import { createFile, - deleteFiles, uploadFile, downloadFile, createAndUploadFile, listFiles, getFileById, - deleteAllFiles, + deleteAllFilesForKind, + deleteFileForFileKind, } from '../../../cases_api_integration/common/lib/api'; import { FtrProviderContext } from '../../ftr_provider_context'; import { casesAllUser, - casesNoDeleteUser, casesReadUser, obsCasesAllUser, - obsCasesNoDeleteUser, obsCasesReadUser, - secAllCasesNoDeleteUser, secAllUser, secReadCasesReadUser, } from './common/users'; +import { + CASES_FILE_KIND, + OBSERVABILITY_FILE_KIND, + SECURITY_SOLUTION_FILE_KIND, +} from '../../../cases_api_integration/common/lib/constants'; interface TestScenario { user: User; fileKind: string; } -const SECURITY_SOLUTION_FILE_KIND = constructFileKindIdByOwner(SECURITY_SOLUTION_APP_ID); -const OBSERVABILITY_FILE_KIND = constructFileKindIdByOwner(OBSERVABILITY_APP_ID); -const CASES_FILE_KIND = constructFileKindIdByOwner(CASES_APP_ID); - export default ({ getService }: FtrProviderContext): void => { const supertestWithoutAuth = getService('supertestWithoutAuth'); const supertest = getService('supertest'); @@ -67,11 +59,12 @@ export default ({ getService }: FtrProviderContext): void => { }; const deleteFileFailure = async (scenario: TestScenario) => { - await deleteFiles({ + await deleteFileForFileKind({ supertest: supertestWithoutAuth, auth: { user: scenario.user, space: null }, - files: [{ kind: scenario.fileKind, id: 'abc' }], - expectedHttpCode: 403, + fileKind: scenario.fileKind, + id: 'abc', + expectedHttpCode: 404, }); }; @@ -120,11 +113,11 @@ export default ({ getService }: FtrProviderContext): void => { }); }; - describe('user not authorized for a delete operation', () => { + describe('delete api does not exist', () => { const testScenarios: TestScenario[] = [ - { user: secAllCasesNoDeleteUser, fileKind: SECURITY_SOLUTION_FILE_KIND }, - { user: casesNoDeleteUser, fileKind: CASES_FILE_KIND }, - { user: obsCasesNoDeleteUser, fileKind: OBSERVABILITY_FILE_KIND }, + { user: secAllUser, fileKind: SECURITY_SOLUTION_FILE_KIND }, + { user: casesAllUser, fileKind: CASES_FILE_KIND }, + { user: obsCasesAllUser, fileKind: OBSERVABILITY_FILE_KIND }, ]; for (const scenario of testScenarios) { @@ -225,7 +218,7 @@ export default ({ getService }: FtrProviderContext): void => { }); afterEach(async () => { - await deleteAllFiles({ + await deleteAllFilesForKind({ supertest, kind: scenario.fileKind, }); @@ -265,27 +258,9 @@ export default ({ getService }: FtrProviderContext): void => { for (const scenario of testScenarios) { describe(`scenario user: ${scenario.user.username} fileKind: ${scenario.fileKind}`, () => { - it('should create and delete a file', async () => { - const createResult = await createFile({ - supertest: supertestWithoutAuth, - auth: { user: scenario.user, space: null }, - params: { - kind: scenario.fileKind, - name: 'testFile', - mimeType: 'image/png', - }, - }); - - await deleteFiles({ - supertest: supertestWithoutAuth, - auth: { user: scenario.user, space: null }, - files: [{ kind: scenario.fileKind, id: createResult.file.id }], - }); - }); - describe('delete created file after test', () => { afterEach(async () => { - await deleteAllFiles({ + await deleteAllFilesForKind({ supertest, kind: scenario.fileKind, }); 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 4408f2f2b38a1..4f035a100e69f 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 @@ -16,6 +16,7 @@ import { CommentRequest, CommentResponse, CommentType, + getCasesDeleteFileAttachmentsUrl, } from '@kbn/cases-plugin/common/api'; import { User } from '../authentication/types'; import { superUser } from '../authentication/users'; @@ -258,3 +259,24 @@ export const updateComment = async ({ return res; }; + +export const bulkDeleteFileAttachments = async ({ + supertest, + caseId, + fileIds, + expectedHttpCode = 204, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest; + caseId: string; + fileIds: string[]; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + await supertest + .post(`${getSpaceUrlPrefix(auth.space)}${getCasesDeleteFileAttachmentsUrl(caseId)}`) + .set('kbn-xsrf', 'true') + .send({ ids: fileIds }) + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); +}; diff --git a/x-pack/test/cases_api_integration/common/lib/api/files.ts b/x-pack/test/cases_api_integration/common/lib/api/files.ts index 05450e9da2cc9..71951564c6a28 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/files.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/files.ts @@ -8,6 +8,8 @@ import type SuperTest from 'supertest'; import { apiRoutes as fileApiRoutes } from '@kbn/files-plugin/public/files_client/files_client'; import { BaseFilesClient } from '@kbn/shared-ux-file-types'; +import { OWNERS } from '@kbn/cases-plugin/common/constants'; +import { constructFileKindIdByOwner } from '@kbn/cases-plugin/common/files'; import { superUser } from '../authentication/users'; import { User } from '../authentication/types'; import { getSpaceUrlPrefix } from './helpers'; @@ -40,8 +42,27 @@ export const downloadFile = async ({ return result; }; +export const deleteFileForFileKind = async ({ + supertest, + fileKind, + id, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest; + fileKind: string; + id: string; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}) => { + await supertest + .delete(`${getSpaceUrlPrefix(auth.space)}${fileApiRoutes.getDeleteRoute(fileKind, id)}`) + .set('kbn-xsrf', 'true') + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); +}; + export interface FileDescriptor { - kind: string; id: string; } @@ -52,28 +73,19 @@ export const deleteFiles = async ({ auth = { user: superUser, space: null }, }: { supertest: SuperTest.SuperTest; - files: FileDescriptor[]; + files: string[]; expectedHttpCode?: number; auth?: { user: User; space: string | null }; }) => { - await Promise.all( - files.map(async (fileInfo) => { - return await supertest - .delete( - `${getSpaceUrlPrefix(auth.space)}${fileApiRoutes.getDeleteRoute( - fileInfo.kind, - fileInfo.id - )}` - ) - .set('kbn-xsrf', 'true') - .auth(auth.user.username, auth.user.password) - .send() - .expect(expectedHttpCode); - }) - ); + await supertest + .delete(`${getSpaceUrlPrefix(auth.space)}${fileApiRoutes.getBulkDeleteRoute()}`) + .set('kbn-xsrf', 'true') + .send({ ids: files }) + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); }; -export const deleteAllFiles = async ({ +export const deleteAllFilesForKind = async ({ supertest, kind, auth = { user: superUser, space: null }, @@ -84,14 +96,66 @@ export const deleteAllFiles = async ({ expectedHttpCode?: number; auth?: { user: User; space: string | null }; }) => { - const files = await listFiles({ supertest, params: { kind }, auth, expectedHttpCode }); + const { body: files } = await supertest + .post(`${getSpaceUrlPrefix(auth.space)}${fileApiRoutes.getFindRoute()}`) + .set('kbn-xsrf', 'true') + .query({ perPage: 10000 }) + .auth(auth.user.username, auth.user.password) + .send({ + kind, + }) + .expect(expectedHttpCode); - await deleteFiles({ - supertest, - files: files.files.map((fileInfo) => ({ kind, id: fileInfo.id })), - auth, - expectedHttpCode, - }); + const castedFiles = files as Awaited>; + + if (castedFiles.files.length > 0) { + await deleteFiles({ + supertest, + files: castedFiles.files.map((fileInfo) => fileInfo.id), + auth, + expectedHttpCode, + }); + } +}; + +export const deleteAllFiles = async ({ + supertest, + auth = { user: superUser, space: null }, + expectedHttpCode = 200, + ignoreErrors = true, +}: { + supertest: SuperTest.SuperTest; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; + ignoreErrors?: boolean; +}) => { + const fileKindOwners = OWNERS.map((owner) => constructFileKindIdByOwner(owner)); + + try { + const { body: files } = await supertest + .post(`${getSpaceUrlPrefix(auth.space)}${fileApiRoutes.getFindRoute()}`) + .set('kbn-xsrf', 'true') + .query({ perPage: 10000 }) + .auth(auth.user.username, auth.user.password) + .send({ + kind: fileKindOwners, + }) + .expect(expectedHttpCode); + const castedFiles = files as Awaited>; + + if (castedFiles.files.length > 0) { + await deleteFiles({ + supertest, + files: castedFiles.files.map((fileInfo) => fileInfo.id), + auth, + expectedHttpCode, + }); + } + } catch (error) { + if (!ignoreErrors) { + throw error; + } + } }; export const getFileById = async ({ diff --git a/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts b/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts index eb11394a0cdf0..49ce36a740ac8 100644 --- a/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts +++ b/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts @@ -142,6 +142,30 @@ export const securitySolutionOnlyDelete: Role = { }, }; +export const securitySolutionOnlyReadDelete: Role = { + name: 'sec_only_read_delete', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + securitySolutionFixture: ['read', 'cases_delete'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + export const securitySolutionOnlyNoDelete: Role = { name: 'sec_only_no_delete', privileges: { @@ -334,6 +358,7 @@ export const roles = [ securitySolutionOnlyRead, securitySolutionOnlyReadAlerts, securitySolutionOnlyDelete, + securitySolutionOnlyReadDelete, securitySolutionOnlyNoDelete, observabilityOnlyAll, observabilityOnlyRead, diff --git a/x-pack/test/cases_api_integration/common/lib/authentication/users.ts b/x-pack/test/cases_api_integration/common/lib/authentication/users.ts index 8d997159cdb06..8a3d5ddb8d30b 100644 --- a/x-pack/test/cases_api_integration/common/lib/authentication/users.ts +++ b/x-pack/test/cases_api_integration/common/lib/authentication/users.ts @@ -20,6 +20,7 @@ import { observabilityOnlyReadAlerts, securitySolutionOnlyReadAlerts, securitySolutionOnlyReadNoIndexAlerts, + securitySolutionOnlyReadDelete, } from './roles'; import { User } from './types'; @@ -47,6 +48,12 @@ export const secOnlyDelete: User = { roles: [securitySolutionOnlyDelete.name], }; +export const secOnlyReadDelete: User = { + username: 'sec_only_read_delete', + password: 'sec_only_read_delete', + roles: [securitySolutionOnlyReadDelete.name], +}; + export const secOnlyNoDelete: User = { username: 'sec_only_no_delete', password: 'sec_only_no_delete', @@ -136,6 +143,7 @@ export const users = [ secOnlyReadAlerts, secSolutionOnlyReadNoIndexAlerts, secOnlyDelete, + secOnlyReadDelete, secOnlyNoDelete, obsOnly, obsOnlyRead, diff --git a/x-pack/test/cases_api_integration/common/lib/constants.ts b/x-pack/test/cases_api_integration/common/lib/constants.ts new file mode 100644 index 0000000000000..fc4d8c4b46b39 --- /dev/null +++ b/x-pack/test/cases_api_integration/common/lib/constants.ts @@ -0,0 +1,17 @@ +/* + * 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 { + GENERAL_CASES_OWNER, + OBSERVABILITY_OWNER, + SECURITY_SOLUTION_OWNER, +} from '@kbn/cases-plugin/common/constants'; +import { constructFileKindIdByOwner } from '@kbn/cases-plugin/common/files'; + +export const SECURITY_SOLUTION_FILE_KIND = constructFileKindIdByOwner(SECURITY_SOLUTION_OWNER); +export const OBSERVABILITY_FILE_KIND = constructFileKindIdByOwner(OBSERVABILITY_OWNER); +export const CASES_FILE_KIND = constructFileKindIdByOwner(GENERAL_CASES_OWNER); diff --git a/x-pack/test/cases_api_integration/common/lib/mock.ts b/x-pack/test/cases_api_integration/common/lib/mock.ts index 149db8b0e316a..bb8233dd058da 100644 --- a/x-pack/test/cases_api_integration/common/lib/mock.ts +++ b/x-pack/test/cases_api_integration/common/lib/mock.ts @@ -25,6 +25,7 @@ import { FILE_ATTACHMENT_TYPE, FileAttachmentMetadata, } from '@kbn/cases-plugin/common/api'; +import { FILE_SO_TYPE } from '@kbn/files-plugin/common'; export const defaultUser = { email: null, full_name: null, username: 'elastic' }; /** @@ -140,6 +141,10 @@ export const getFilesAttachmentReq = ( ): CommentRequestExternalReferenceSOType => { return { ...postExternalReferenceSOReq, + externalReferenceStorage: { + type: ExternalReferenceStorageType.savedObject, + soType: FILE_SO_TYPE, + }, externalReferenceAttachmentTypeId: FILE_ATTACHMENT_TYPE, externalReferenceMetadata: { ...fileAttachmentMetadata }, ...req, diff --git a/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc b/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc index f80753ebbe744..3989d35f8a2aa 100644 --- a/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc +++ b/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc @@ -8,7 +8,8 @@ "browser": false, "requiredPlugins": [ "features", - "cases" + "cases", + "files", ], "optionalPlugins": [ "security", diff --git a/x-pack/test/cases_api_integration/common/plugins/cases/server/files/index.ts b/x-pack/test/cases_api_integration/common/plugins/cases/server/files/index.ts new file mode 100644 index 0000000000000..40e3c4410c58f --- /dev/null +++ b/x-pack/test/cases_api_integration/common/plugins/cases/server/files/index.ts @@ -0,0 +1,43 @@ +/* + * 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 { HttpApiTagOperation } from '@kbn/cases-plugin/common/constants/types'; +import type { FileKind } from '@kbn/files-plugin/common'; +import type { FilesSetup } from '@kbn/files-plugin/server'; + +export const CASES_TEST_FIXTURE_OWNER = 'casesTestFixtureOwner'; +export const CASES_TEST_FIXTURE_FILE_KIND_ID = `${CASES_TEST_FIXTURE_OWNER}_file_kind_id`; + +const buildFileKind = (): FileKind => { + return { + id: CASES_TEST_FIXTURE_FILE_KIND_ID, + http: fileKindHttpTags(), + allowedMimeTypes: ['image/png'], + }; +}; + +const fileKindHttpTags = (): FileKind['http'] => { + return { + create: buildTag(HttpApiTagOperation.Create), + download: buildTag(HttpApiTagOperation.Read), + getById: buildTag(HttpApiTagOperation.Read), + list: buildTag(HttpApiTagOperation.Read), + }; +}; + +const access = 'access:'; + +const buildTag = (operation: HttpApiTagOperation) => { + return { + tags: [`${access}${CASES_TEST_FIXTURE_OWNER}${operation}`], + }; +}; + +export const registerCaseFixtureFileKinds = (filesSetupPlugin: FilesSetup) => { + const fileKind = buildFileKind(); + filesSetupPlugin.registerFileKind(fileKind); +}; diff --git a/x-pack/test/cases_api_integration/common/plugins/cases/server/plugin.ts b/x-pack/test/cases_api_integration/common/plugins/cases/server/plugin.ts index 9b92917544d4b..90ef74625119e 100644 --- a/x-pack/test/cases_api_integration/common/plugins/cases/server/plugin.ts +++ b/x-pack/test/cases_api_integration/common/plugins/cases/server/plugin.ts @@ -10,13 +10,16 @@ import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin import { SpacesPluginStart } from '@kbn/spaces-plugin/server'; import { SecurityPluginStart } from '@kbn/security-plugin/server'; import type { CasesStart, CasesSetup } from '@kbn/cases-plugin/server'; +import { FilesSetup } from '@kbn/files-plugin/server'; import { getPersistableStateAttachment } from './attachments/persistable_state'; import { getExternalReferenceAttachment } from './attachments/external_reference'; import { registerRoutes } from './routes'; +import { registerCaseFixtureFileKinds } from './files'; export interface FixtureSetupDeps { features: FeaturesPluginSetup; cases: CasesSetup; + files: FilesSetup; } export interface FixtureStartDeps { @@ -36,6 +39,7 @@ export class FixturePlugin implements Plugin { loadTestFile(require.resolve('./internal/bulk_get_attachments')); loadTestFile(require.resolve('./internal/get_connectors')); loadTestFile(require.resolve('./internal/user_actions_get_users')); + loadTestFile(require.resolve('./internal/bulk_delete_file_attachments')); /** * Attachments framework diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_delete_file_attachments.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_delete_file_attachments.ts new file mode 100644 index 0000000000000..2a65a0a2fbdc3 --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_delete_file_attachments.ts @@ -0,0 +1,837 @@ +/* + * 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 expect from '@kbn/expect'; +import { CaseResponse } from '@kbn/cases-plugin/common'; +import { constructFileKindIdByOwner } from '@kbn/cases-plugin/common/files'; +import { Owner } from '@kbn/cases-plugin/common/constants/types'; +import { CASES_TEST_FIXTURE_FILE_KIND_ID } from '@kbn/cases-api-integration-test-plugin/server/files'; +import { getFilesAttachmentReq, getPostCaseRequest } from '../../../../common/lib/mock'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { + bulkCreateAttachments, + bulkGetAttachments, + createAndUploadFile, + createCase, + createFile, + deleteAllCaseItems, + deleteAllFiles, + bulkDeleteFileAttachments, + getComment, + listFiles, +} from '../../../../common/lib/api'; +import { SECURITY_SOLUTION_FILE_KIND } from '../../../../common/lib/constants'; +import { + globalRead, + noKibanaPrivileges, + superUser, +} from '../../../../common/lib/authentication/users'; +import { createUsersAndRoles, deleteUsersAndRoles } from '../../../../common/lib/authentication'; +import { + casesAllUser, + obsCasesAllUser, + obsCasesReadUser, + obsSecCasesAllUser, + obsSecCasesReadUser, + secAllSpace1User, + secAllUser, + secReadUser, + 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'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('bulk_delete_file_attachments', () => { + // 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); + }); + + describe('failures', () => { + let postedCase: CaseResponse; + + before(async () => { + postedCase = await createCase(supertest, getPostCaseRequest()); + }); + + after(async () => { + await deleteAllFiles({ + supertest, + }); + await deleteAllCaseItems(es); + }); + + it('returns a 400 when attempting to delete a file with a file kind that is not within a case plugin', async () => { + const postedSecCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolution' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + const create = await createFile({ + supertest: supertestWithoutAuth, + params: { + name: 'testfile', + kind: CASES_TEST_FIXTURE_FILE_KIND_ID, + mimeType: 'text/plain', + meta: { + caseIds: [postedSecCase.id], + owner: ['securitySolution'], + }, + }, + auth: { user: superUser, space: 'space1' }, + }); + + await bulkCreateAttachments({ + supertest: supertestWithoutAuth, + caseId: postedSecCase.id, + params: [ + getFilesAttachmentReq({ + externalReferenceId: create.file.id, + owner: 'securitySolution', + }), + ], + auth: { user: superUser, space: 'space1' }, + }); + + await bulkDeleteFileAttachments({ + supertest: supertestWithoutAuth, + caseId: postedSecCase.id, + fileIds: [create.file.id], + auth: { user: superUser, space: 'space1' }, + expectedHttpCode: 400, + }); + }); + + it('fails to delete a file when the file does not exist', async () => { + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: ['abc'], + expectedHttpCode: 404, + }); + }); + + it('returns a 400 when the fileIds is an empty array', async () => { + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [], + expectedHttpCode: 400, + }); + }); + + it('returns a 400 when the a fileId is an empty string', async () => { + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: ['abc', ''], + expectedHttpCode: 400, + }); + }); + + it('returns a 400 when there are 51 ids being deleted', async () => { + const ids = Array.from(Array(51).keys()).map((item) => item.toString()); + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: ids, + expectedHttpCode: 400, + }); + }); + + it('fails to delete a file when the case id is not within the metadata', async () => { + const create = await createFile({ + supertest, + params: { + name: 'testfile', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + owner: [postedCase.owner], + }, + }, + }); + + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [create.file.id], + expectedHttpCode: 400, + }); + }); + + it('fails to delete a file when the case id does not match the id in the request', async () => { + const create = await createFile({ + supertest, + params: { + name: 'testfile', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: ['abc'], + owner: [postedCase.owner], + }, + }, + }); + + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [create.file.id], + expectedHttpCode: 400, + }); + }); + + it('fails to delete a file when the case id is not an array', async () => { + const create = await createFile({ + supertest, + params: { + name: 'testfile', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: postedCase.id, + owner: [postedCase.owner], + }, + }, + }); + + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [create.file.id], + expectedHttpCode: 400, + }); + }); + + it('fails to delete a file when the case ids is an array of more than one item', async () => { + const create = await createFile({ + supertest, + params: { + name: 'testfile', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id, postedCase.id], + owner: [postedCase.owner], + }, + }, + }); + + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [create.file.id], + expectedHttpCode: 400, + }); + }); + }); + + describe('deletes files when there are no case attachments', () => { + afterEach(async () => { + await deleteAllFiles({ + supertest, + }); + await deleteAllCaseItems(es); + }); + + it('deletes a file when the owner is not formatted as an array of strings', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const create = await createFile({ + supertest, + params: { + name: 'testfile', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + owner: postedCase.owner, + }, + }, + }); + + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [create.file.id], + expectedHttpCode: 204, + }); + }); + + it('deletes a file when the owner is not within the metadata', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const create = await createFile({ + supertest, + params: { + name: 'testfile', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + }, + }, + }); + + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [create.file.id], + expectedHttpCode: 204, + }); + }); + + it('deletes a single file', async () => { + const postedCase = await createCase( + supertest, + getPostCaseRequest({ owner: 'securitySolution' }) + ); + + const { create } = await createAndUploadFile({ + supertest, + createFileParams: { + name: 'testfile', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + owner: [postedCase.owner], + }, + }, + data: 'abc', + }); + + const filesBeforeDelete = await listFiles({ + supertest, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + }); + + expect(filesBeforeDelete.total).to.be(1); + + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [create.file.id], + }); + + const filesAfterDelete = await listFiles({ + supertest, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + }); + + expect(filesAfterDelete.total).to.be(0); + }); + + it('deletes multiple files', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const [fileInfo1, fileInfo2] = await Promise.all([ + createAndUploadFile({ + supertest, + createFileParams: { + name: 'file1', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + owner: [postedCase.owner], + }, + }, + data: 'abc', + }), + createAndUploadFile({ + supertest, + createFileParams: { + name: 'file2', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + owner: [postedCase.owner], + }, + }, + data: 'abc', + }), + ]); + + const filesBeforeDelete = await listFiles({ + supertest, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + }); + + expect(filesBeforeDelete.total).to.be(2); + + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [fileInfo1.create.file.id, fileInfo2.create.file.id], + }); + + const filesAfterDelete = await listFiles({ + supertest, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + }); + + expect(filesAfterDelete.total).to.be(0); + }); + }); + + describe('deletes files when there are case attachments', () => { + afterEach(async () => { + await deleteAllFiles({ + supertest, + }); + await deleteAllCaseItems(es); + }); + + it('deletes a single file', async () => { + const postedCase = await createCase( + supertest, + getPostCaseRequest({ owner: 'securitySolution' }) + ); + + const { create } = await createAndUploadFile({ + supertest, + createFileParams: { + name: 'testfile', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + owner: [postedCase.owner], + }, + }, + data: 'abc', + }); + + const filesBeforeDelete = await listFiles({ + supertest, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + }); + + expect(filesBeforeDelete.total).to.be(1); + + const caseWithAttachments = await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [ + getFilesAttachmentReq({ + externalReferenceId: create.file.id, + owner: 'securitySolution', + }), + ], + }); + + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [create.file.id], + }); + + const filesAfterDelete = await listFiles({ + supertest, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + }); + + expect(filesAfterDelete.total).to.be(0); + + await getComment({ + supertest, + caseId: postedCase.id, + commentId: caseWithAttachments.comments![0].id, + expectedHttpCode: 404, + }); + }); + + it('deletes multiple files', async () => { + const postedCase = await createCase( + supertest, + getPostCaseRequest({ owner: 'securitySolution' }), + 200 + ); + + const [fileInfo1, fileInfo2] = await Promise.all([ + createAndUploadFile({ + supertest, + createFileParams: { + name: 'file1', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + owner: [postedCase.owner], + }, + }, + data: 'abc', + }), + createAndUploadFile({ + supertest, + createFileParams: { + name: 'file2', + kind: SECURITY_SOLUTION_FILE_KIND, + mimeType: 'text/plain', + meta: { + caseIds: [postedCase.id], + owner: [postedCase.owner], + }, + }, + data: 'abc', + }), + ]); + + const filesBeforeDelete = await listFiles({ + supertest, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + }); + + expect(filesBeforeDelete.total).to.be(2); + + const caseWithAttachments = await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [ + getFilesAttachmentReq({ + externalReferenceId: fileInfo1.create.file.id, + owner: 'securitySolution', + }), + getFilesAttachmentReq({ + externalReferenceId: fileInfo2.create.file.id, + owner: 'securitySolution', + }), + ], + }); + + await bulkDeleteFileAttachments({ + supertest, + caseId: postedCase.id, + fileIds: [fileInfo1.create.file.id, fileInfo2.create.file.id], + }); + + const filesAfterDelete = await listFiles({ + supertest, + params: { + kind: SECURITY_SOLUTION_FILE_KIND, + }, + }); + + expect(filesAfterDelete.total).to.be(0); + + const bulkGetAttachmentsResponse = await bulkGetAttachments({ + supertest, + attachmentIds: [caseWithAttachments.comments![0].id, caseWithAttachments.comments![1].id], + caseId: postedCase.id, + }); + + expect(bulkGetAttachmentsResponse.attachments.length).to.be(0); + expect(bulkGetAttachmentsResponse.errors[0].status).to.be(404); + expect(bulkGetAttachmentsResponse.errors[1].status).to.be(404); + }); + }); + + describe('rbac', () => { + after(async () => { + await deleteAllFiles({ + supertest, + }); + await deleteAllCaseItems(es); + }); + + for (const scenario of [ + { user: obsCasesAllUser, owner: 'observability' }, + { user: secAllUser, owner: 'securitySolution' }, + { user: casesAllUser, owner: 'cases' }, + { user: obsSecCasesAllUser, owner: 'securitySolution' }, + { user: obsSecCasesAllUser, owner: 'observability' }, + ]) { + it(`successfully deletes a file for user ${scenario.user.username} with owner ${scenario.owner} when an attachment does not exist`, async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: scenario.owner }), + 200, + { user: superUser, space: 'space1' } + ); + + const create = await createFile({ + supertest: supertestWithoutAuth, + params: { + name: 'testfile', + kind: constructFileKindIdByOwner(scenario.owner as Owner), + mimeType: 'text/plain', + meta: { + caseIds: [caseInfo.id], + owner: [scenario.owner], + }, + }, + auth: { user: superUser, space: 'space1' }, + }); + + await bulkDeleteFileAttachments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + fileIds: [create.file.id], + auth: { user: scenario.user, space: 'space1' }, + }); + }); + + it(`successfully deletes a file for user ${scenario.user.username} with owner ${scenario.owner} when an attachment exists`, async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: scenario.owner }), + 200, + { user: superUser, space: 'space1' } + ); + + const create = await createFile({ + supertest: supertestWithoutAuth, + params: { + name: 'testfile', + kind: constructFileKindIdByOwner(scenario.owner as Owner), + mimeType: 'text/plain', + meta: { + caseIds: [caseInfo.id], + owner: [scenario.owner], + }, + }, + auth: { user: superUser, space: 'space1' }, + }); + + await bulkCreateAttachments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: [ + getFilesAttachmentReq({ + externalReferenceId: create.file.id, + owner: scenario.owner, + }), + ], + auth: { user: superUser, space: 'space1' }, + }); + + await bulkDeleteFileAttachments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + fileIds: [create.file.id], + auth: { user: scenario.user, space: 'space1' }, + }); + }); + } + + for (const scenario of [ + { + user: obsCasesAllUser, + owner: 'securitySolution', + }, + { + user: globalRead, + owner: 'securitySolution', + }, + { + user: secReadUser, + owner: 'securitySolution', + }, + { + user: obsCasesReadUser, + owner: 'securitySolution', + }, + { + user: obsSecCasesReadUser, + owner: 'securitySolution', + }, + { + user: noKibanaPrivileges, + owner: 'securitySolution', + }, + { user: secAllUser, owner: 'observability' }, + ]) { + // these tests should fail when checking if the user is authorized to delete a file with the file kind + it(`returns a 403 for user ${scenario.user.username} when attempting to delete a file with owner ${scenario.owner} that does not have an attachment`, async () => { + const postedSecCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: scenario.owner }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + const create = await createFile({ + supertest: supertestWithoutAuth, + params: { + name: 'testfile', + kind: constructFileKindIdByOwner(scenario.owner as Owner), + mimeType: 'text/plain', + meta: { + caseIds: [postedSecCase.id], + owner: [scenario.owner], + }, + }, + auth: { user: superUser, space: 'space1' }, + }); + + await bulkDeleteFileAttachments({ + supertest: supertestWithoutAuth, + caseId: postedSecCase.id, + fileIds: [create.file.id], + auth: { user: scenario.user, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + } + + for (const scenario of [ + { + user: obsCasesAllUser, + fileOwner: 'observability', + attachmentOwner: 'securitySolution', + }, + { + user: globalRead, + fileOwner: 'securitySolution', + attachmentOwner: 'securitySolution', + }, + { + user: secReadUser, + fileOwner: 'securitySolution', + attachmentOwner: 'securitySolution', + }, + { + user: obsCasesReadUser, + fileOwner: 'observability', + attachmentOwner: 'securitySolution', + }, + { + user: obsSecCasesReadUser, + fileOwner: 'observability', + attachmentOwner: 'securitySolution', + }, + { + user: noKibanaPrivileges, + fileOwner: 'securitySolution', + attachmentOwner: 'securitySolution', + }, + { + user: secAllUser, + fileOwner: 'securitySolution', + attachmentOwner: 'observability', + }, + ]) { + // these tests should fail when checking the user is authorized for the attachment's owner so the user will have + // access to delete the file saved object but not the attachment + it(`returns a 403 for user ${scenario.user.username} when attempting to delete a file when the attachment has owner ${scenario.attachmentOwner}`, async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: scenario.attachmentOwner }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + const create = await createFile({ + supertest: supertestWithoutAuth, + params: { + name: 'testfile', + kind: constructFileKindIdByOwner(scenario.fileOwner as Owner), + mimeType: 'text/plain', + meta: { + caseIds: [caseInfo.id], + owner: [scenario.fileOwner], + }, + }, + auth: { user: superUser, space: 'space1' }, + }); + + await bulkCreateAttachments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: [ + getFilesAttachmentReq({ + externalReferenceId: create.file.id, + owner: scenario.attachmentOwner, + }), + ], + auth: { user: superUser, space: 'space1' }, + }); + + await bulkDeleteFileAttachments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + fileIds: [create.file.id], + auth: { user: scenario.user, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + } + + it('returns a 403 when attempting to delete files from a space the user does not have permissions to', async () => { + const postedSecCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolution' }), + 200, + { + user: superUser, + space: 'space2', + } + ); + + const create = await createFile({ + supertest: supertestWithoutAuth, + params: { + name: 'testfile', + kind: constructFileKindIdByOwner('securitySolution'), + mimeType: 'text/plain', + meta: { + caseIds: [postedSecCase.id], + owner: ['securitySolution'], + }, + }, + auth: { user: superUser, space: 'space2' }, + }); + + await bulkDeleteFileAttachments({ + supertest: supertestWithoutAuth, + caseId: postedSecCase.id, + fileIds: [create.file.id], + auth: { user: secAllSpace1User, space: 'space2' }, + expectedHttpCode: 403, + }); + }); + }); + }); +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts index 65391b73f9f62..9cf0aaa18be46 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts @@ -42,6 +42,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => { loadTestFile(require.resolve('./internal/suggest_user_profiles')); loadTestFile(require.resolve('./internal/get_connectors')); loadTestFile(require.resolve('./internal/user_actions_get_users')); + loadTestFile(require.resolve('./internal/bulk_delete_file_attachments')); // Common loadTestFile(require.resolve('../common')); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/bulk_delete_file_attachments.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/bulk_delete_file_attachments.ts new file mode 100644 index 0000000000000..c57cbe69d9f0f --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/bulk_delete_file_attachments.ts @@ -0,0 +1,138 @@ +/* + * 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 { constructFileKindIdByOwner } from '@kbn/cases-plugin/common/files'; +import { Owner } from '@kbn/cases-plugin/common/constants/types'; +import { getFilesAttachmentReq, getPostCaseRequest } from '../../../../common/lib/mock'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { + bulkCreateAttachments, + createCase, + createFile, + deleteAllCaseItems, + deleteAllFiles, + bulkDeleteFileAttachments, +} from '../../../../common/lib/api'; +import { superUser } from '../../../../common/lib/authentication/users'; +import { createUsersAndRoles, deleteUsersAndRoles } from '../../../../common/lib/authentication'; +import { + casesOnlyReadDeleteUser, + obsCasesOnlyReadDeleteUser, + secAllCasesOnlyReadDeleteUser, + 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'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('delete_file_attachments deletion sub privilege', () => { + // 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); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + after(async () => { + await deleteAllFiles({ + supertest, + }); + await deleteAllCaseItems(es); + }); + + for (const scenario of [ + { + user: secAllCasesOnlyReadDeleteUser, + owner: 'securitySolution', + }, + { user: obsCasesOnlyReadDeleteUser, owner: 'observability' }, + { user: casesOnlyReadDeleteUser, owner: 'cases' }, + ]) { + it(`successfully deletes a file for user ${scenario.user.username} with owner ${scenario.owner} when an attachment does not exist`, async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: scenario.owner }), + 200, + { user: superUser, space: 'space1' } + ); + + const create = await createFile({ + supertest: supertestWithoutAuth, + params: { + name: 'testfile', + kind: constructFileKindIdByOwner(scenario.owner as Owner), + mimeType: 'text/plain', + meta: { + caseIds: [caseInfo.id], + owner: [scenario.owner], + }, + }, + auth: { user: superUser, space: 'space1' }, + }); + + await bulkDeleteFileAttachments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + fileIds: [create.file.id], + auth: { user: scenario.user, space: 'space1' }, + }); + }); + + it(`successfully deletes a file for user ${scenario.user.username} with owner ${scenario.owner} when an attachment exists`, async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: scenario.owner }), + 200, + { user: superUser, space: 'space1' } + ); + + const create = await createFile({ + supertest: supertestWithoutAuth, + params: { + name: 'testfile', + kind: constructFileKindIdByOwner(scenario.owner as Owner), + mimeType: 'text/plain', + meta: { + caseIds: [caseInfo.id], + owner: [scenario.owner], + }, + }, + auth: { user: superUser, space: 'space1' }, + }); + + await bulkCreateAttachments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: [ + getFilesAttachmentReq({ + externalReferenceId: create.file.id, + owner: scenario.owner, + }), + ], + auth: { user: superUser, space: 'space1' }, + }); + + await bulkDeleteFileAttachments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + fileIds: [create.file.id], + auth: { user: scenario.user, space: 'space1' }, + }); + }); + } + }); + }); +};