Skip to content

Commit

Permalink
[Cases] Delete file API (#153604)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
jonathan-buttner and kibanamachine authored Apr 3, 2023
1 parent 9c67d83 commit 1e63515
Show file tree
Hide file tree
Showing 54 changed files with 2,228 additions and 201 deletions.
10 changes: 10 additions & 0 deletions x-pack/plugins/cases/common/api/cases/comment/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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>;
5 changes: 5 additions & 0 deletions x-pack/plugins/cases/common/api/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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);
};
10 changes: 2 additions & 8 deletions x-pack/plugins/cases/common/constants/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 2 additions & 0 deletions x-pack/plugins/cases/common/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions x-pack/plugins/cases/common/files/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
43 changes: 43 additions & 0 deletions x-pack/plugins/cases/common/files/index.ts
Original file line number Diff line number Diff line change
@@ -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;
};
57 changes: 57 additions & 0 deletions x-pack/plugins/cases/common/schema/index.test.ts
Original file line number Diff line number Diff line change
@@ -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!",
]
`);
});
});
42 changes: 42 additions & 0 deletions x-pack/plugins/cases/common/schema/index.ts
Original file line number Diff line number Diff line change
@@ -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
);
7 changes: 2 additions & 5 deletions x-pack/plugins/cases/common/utils/api_tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion x-pack/plugins/cases/public/files/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 1e63515

Please sign in to comment.