[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>
This commit is contained in:
Jonathan Buttner 2023-04-03 12:39:31 -04:00 committed by GitHub
parent 9c67d835bf
commit 1e63515170
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 2228 additions and 201 deletions

View file

@ -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<typeof FileAttachmentMetadataRt>;
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<typeof BulkDeleteFileAttachmentsRequestRt>;

View file

@ -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);
};

View file

@ -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;

View file

@ -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

View file

@ -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');
});
});
});

View file

@ -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<typeof CaseFileMetadataForDeletionRt>;
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;
};

View file

@ -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!",
]
`);
});
});

View file

@ -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<string, string, unknown>(
'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 = <T extends rt.Mixed>(codec: T, min: number, max: number) =>
new rt.Type<Array<rt.TypeOf<typeof codec>>, Array<rt.TypeOf<typeof codec>>, 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
);

View file

@ -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);

View file

@ -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 {

View file

@ -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;
};

View file

@ -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<CommentAttributes>;
type AttachmentSavedObject = SavedObject<CommentAttributes>;
/**
* Retrieves multiple attachments by id.
*/
@ -126,17 +124,6 @@ const partitionBySOError = (attachments: Array<OptionalAttributes<CommentAttribu
(attachment) => 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,

View file

@ -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<void>;
bulkDeleteFileAttachments(deleteArgs: BulkDeleteFileArgs): Promise<void>;
/**
* 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);

View file

@ -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<CommentAttributes>) =>
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,
});

View file

@ -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.
*/

View file

@ -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,
});
}

View file

@ -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(),
};
};

View file

@ -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<

View file

@ -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() {

View file

@ -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 [],
]
`);
});
});
});

View file

@ -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;
});

View file

@ -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');
});
});
});

View file

@ -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;
};

View file

@ -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<CommentAttributes>;

View file

@ -88,8 +88,8 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => {
}),
includeIn: 'all',
savedObject: {
all: [],
read: [],
all: [...filesSavedObjectTypes],
read: [...filesSavedObjectTypes],
},
cases: {
delete: [APP_ID],

View file

@ -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;

View file

@ -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;

View file

@ -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[];

View file

@ -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,
});
}
},
});

View file

@ -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<string, estypes.AggregationsAggregationContainer>;
}
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;
}
}

View file

@ -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<Array<SavedObject<CommentAttributes>>> {
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<AttachmentAttributesWithoutRefs>(
{
type: CASE_COMMENT_SAVED_OBJECT,
hasReference: references,
sortField: 'created_at',
sortOrder: 'asc',
perPage: MAX_DOCS_PER_PAGE,
}
);
const foundAttachments: Array<SavedObject<CommentAttributes>> = [];
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}`
);
}
}
}

View file

@ -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<AttachmentService> & 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(),

View file

@ -288,6 +288,11 @@ export class UserActionPersister {
refresh,
}: BulkCreateAttachmentUserAction): Promise<void> {
this.context.log.debug(`Attempting to create a bulk create case user action`);
if (attachments.length <= 0) {
return;
}
const userActions = attachments.reduce<UserActionEvent[]>((acc, attachment) => {
const userActionBuilder = this.builderFactory.getBuilder(ActionTypes.comment);
const commentUserAction = userActionBuilder?.build({

View file

@ -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<CaseUserActionAttributesWithoutConnectorId>,
@ -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;
}

View file

@ -134,8 +134,8 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
),
includeIn: 'all',
savedObject: {
all: [],
read: [],
all: [...filesSavedObjectTypes],
read: [...filesSavedObjectTypes],
},
cases: {
delete: [observabilityFeatureId],

View file

@ -86,8 +86,8 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => {
),
includeIn: 'all',
savedObject: {
all: [],
read: [],
all: [...filesSavedObjectTypes],
read: [...filesSavedObjectTypes],
},
cases: {
delete: [APP_ID],

View file

@ -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,

View file

@ -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,
];

View file

@ -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,
});

View file

@ -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<SuperTest.Test>;
caseId: string;
fileIds: string[];
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<void> => {
await supertest
.post(`${getSpaceUrlPrefix(auth.space)}${getCasesDeleteFileAttachmentsUrl(caseId)}`)
.set('kbn-xsrf', 'true')
.send({ ids: fileIds })
.auth(auth.user.username, auth.user.password)
.expect(expectedHttpCode);
};

View file

@ -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<SuperTest.Test>;
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<SuperTest.Test>;
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<ReturnType<BaseFilesClient['find']>>;
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<SuperTest.Test>;
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<ReturnType<BaseFilesClient['find']>>;
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 ({

View file

@ -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,

View file

@ -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,

View file

@ -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);

View file

@ -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,

View file

@ -8,7 +8,8 @@
"browser": false,
"requiredPlugins": [
"features",
"cases"
"cases",
"files",
],
"optionalPlugins": [
"security",

View file

@ -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);
};

View file

@ -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<void, void, FixtureSetupDeps, Fixtu
deps.cases.attachmentFramework.registerPersistableState(getPersistableStateAttachment());
registerRoutes(core, this.log);
registerCaseFixtureFileKinds(deps.files);
}
public start(core: CoreStart, plugins: FixtureStartDeps) {}

View file

@ -6,7 +6,7 @@
*/
import { Plugin, CoreSetup } from '@kbn/core/server';
import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects';
import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server';
import { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import { SecurityPluginStart } from '@kbn/security-plugin/server';
@ -37,8 +37,8 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
all: ['observabilityFixture'],
},
savedObject: {
all: [],
read: [],
all: [...filesSavedObjectTypes],
read: [...filesSavedObjectTypes],
},
ui: [],
},
@ -50,7 +50,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
},
savedObject: {
all: [],
read: [],
read: [...filesSavedObjectTypes],
},
ui: [],
},

View file

@ -6,7 +6,7 @@
*/
import { Plugin, CoreSetup } from '@kbn/core/server';
import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects';
import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server';
import { SpacesPluginStart } from '@kbn/spaces-plugin/server';
import { SecurityPluginStart } from '@kbn/security-plugin/server';
@ -48,8 +48,8 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
push: ['securitySolutionFixture'],
},
savedObject: {
all: [],
read: [],
all: [...filesSavedObjectTypes],
read: [...filesSavedObjectTypes],
},
ui: [],
},
@ -61,7 +61,7 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
},
savedObject: {
all: [],
read: [],
read: [...filesSavedObjectTypes],
},
ui: [],
},
@ -81,8 +81,8 @@ export class FixturePlugin implements Plugin<void, void, FixtureSetupDeps, Fixtu
delete: ['securitySolutionFixture'],
},
savedObject: {
all: [],
read: [],
all: [...filesSavedObjectTypes],
read: [...filesSavedObjectTypes],
},
ui: [],
},

View file

@ -50,6 +50,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
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

View file

@ -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,
});
});
});
});
};

View file

@ -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'));

View file

@ -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' },
});
});
}
});
});
};