From 234d48d9bc856202990a22c8ebe912063afc9d5a Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Mon, 3 Apr 2023 15:01:01 -0400 Subject: [PATCH] [Cases] Adding files configuration fields (#154013) Fixes: https://github.com/elastic/kibana/issues/151935 This PR allows the mime types and max file size for the files functionality within cases to be configured through the kibana.yml. We set the defaults maxSize to be 100 mb and if it is not set by the user we also restrict images to be 10 mb. If the `maxSize` is set by the user we use it for all mime types including images (or whatever the user has specified in `allowedMimeTypes`). The file service changes are just mocks to help with testing some of the configuration options. New fields ``` { files: { allowedMimeTypes: string[] maxSize: positive number (minimum 0) <-- exposed to the browser } } ``` ## Release Notes Cases added two configuration options to allow users to control which files mime types are allowed to be attached to cases and the approved max size of a file being upload. `xpack.cases.files.allowedMimeTypes` - An array of strings representing the allowed mime types to be attached to a case. `xpack.cases.files.maxSize` - A number representing the file size limit for files being attached to a case (in bytes). --- src/plugins/files/public/mocks.ts | 17 +- src/plugins/files/server/mocks.ts | 8 +- .../test_suites/core_plugins/rendering.ts | 2 + .../plugins/cases/common/constants/files.ts | 1 + .../plugins/cases/common/constants/owners.ts | 2 +- x-pack/plugins/cases/common/ui/types.ts | 4 + .../public/common/lib/kibana/services.ts | 5 +- .../plugins/cases/public/files/index.test.ts | 98 +++ x-pack/plugins/cases/public/files/index.ts | 29 +- x-pack/plugins/cases/public/files/types.ts | 14 + x-pack/plugins/cases/public/plugin.ts | 11 +- x-pack/plugins/cases/server/config.test.ts | 113 +++ x-pack/plugins/cases/server/config.ts | 8 + .../plugins/cases/server/files/index.test.ts | 720 ++++++++++++++++++ x-pack/plugins/cases/server/files/index.ts | 53 +- x-pack/plugins/cases/server/files/types.ts | 10 + x-pack/plugins/cases/server/index.ts | 1 + x-pack/plugins/cases/server/plugin.ts | 5 +- 18 files changed, 1065 insertions(+), 36 deletions(-) create mode 100644 x-pack/plugins/cases/public/files/index.test.ts create mode 100644 x-pack/plugins/cases/public/files/types.ts create mode 100644 x-pack/plugins/cases/server/config.test.ts create mode 100644 x-pack/plugins/cases/server/files/index.test.ts create mode 100644 x-pack/plugins/cases/server/files/types.ts diff --git a/src/plugins/files/public/mocks.ts b/src/plugins/files/public/mocks.ts index c22d9bda24608..447f8c2b85d54 100644 --- a/src/plugins/files/public/mocks.ts +++ b/src/plugins/files/public/mocks.ts @@ -8,10 +8,25 @@ import { createMockFilesClient as createBaseMocksFilesClient } from '@kbn/shared-ux-file-mocks'; import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; -import type { FilesClient } from './types'; +import { FilesSetup } from '.'; +import type { FilesClient, FilesClientFactory } from './types'; export const createMockFilesClient = (): DeeplyMockedKeys => ({ ...createBaseMocksFilesClient(), getMetrics: jest.fn(), publicDownload: jest.fn(), }); + +export const createMockFilesSetup = (): DeeplyMockedKeys => { + return { + filesClientFactory: createMockFilesClientFactory(), + registerFileKind: jest.fn(), + }; +}; + +export const createMockFilesClientFactory = (): DeeplyMockedKeys => { + return { + asScoped: jest.fn(), + asUnscoped: jest.fn(), + }; +}; diff --git a/src/plugins/files/server/mocks.ts b/src/plugins/files/server/mocks.ts index 60f0b5c38ee8d..8472717b544a1 100644 --- a/src/plugins/files/server/mocks.ts +++ b/src/plugins/files/server/mocks.ts @@ -10,7 +10,7 @@ import { KibanaRequest } from '@kbn/core/server'; import { DeeplyMockedKeys } from '@kbn/utility-types-jest'; import * as stream from 'stream'; import { File } from '../common'; -import { FileClient, FileServiceFactory, FileServiceStart } from '.'; +import { FileClient, FileServiceFactory, FileServiceStart, FilesSetup } from '.'; export const createFileServiceMock = (): DeeplyMockedKeys => ({ create: jest.fn(), @@ -78,3 +78,9 @@ export const createFileClientMock = (): DeeplyMockedKeys => { listShares: jest.fn().mockResolvedValue({ shares: [] }), }; }; + +export const createFilesSetupMock = (): DeeplyMockedKeys => { + return { + registerFileKind: jest.fn(), + }; +}; diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 0ce5819e3f973..2e462dd66bb1b 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -165,6 +165,8 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.apm.serviceMapEnabled (boolean)', 'xpack.apm.ui.enabled (boolean)', 'xpack.apm.ui.maxTraceItems (number)', + 'xpack.cases.files.allowedMimeTypes (array)', + 'xpack.cases.files.maxSize (number)', 'xpack.cases.markdownPlugins.lens (boolean)', 'xpack.ccr.ui.enabled (boolean)', 'xpack.cloud.base_url (string)', diff --git a/x-pack/plugins/cases/common/constants/files.ts b/x-pack/plugins/cases/common/constants/files.ts index 572b612e2efc2..3c7a8d80d9ae6 100644 --- a/x-pack/plugins/cases/common/constants/files.ts +++ b/x-pack/plugins/cases/common/constants/files.ts @@ -6,5 +6,6 @@ */ export const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MiB +export const MAX_IMAGE_FILE_SIZE = 10 * 1024 * 1024; // 10 MiB export const MAX_FILES_PER_CASE = 100; export const MAX_DELETE_FILES = 50; diff --git a/x-pack/plugins/cases/common/constants/owners.ts b/x-pack/plugins/cases/common/constants/owners.ts index 60463fa57a976..3e799030c7d5b 100644 --- a/x-pack/plugins/cases/common/constants/owners.ts +++ b/x-pack/plugins/cases/common/constants/owners.ts @@ -15,7 +15,7 @@ export const SECURITY_SOLUTION_OWNER = 'securitySolution' as const; export const OBSERVABILITY_OWNER = 'observability' as const; export const GENERAL_CASES_OWNER = APP_ID; -export const OWNERS = [SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER, GENERAL_CASES_OWNER] as const; +export const OWNERS = [GENERAL_CASES_OWNER, OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER] as const; interface RouteInfo { id: Owner; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 06765eb17ca4f..e9bfcd0682f5b 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -52,6 +52,10 @@ export interface CasesUiConfigType { markdownPlugins: { lens: boolean; }; + files: { + maxSize?: number; + allowedMimeTypes: string[]; + }; } export const StatusAll = 'all' as const; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/services.ts b/x-pack/plugins/cases/public/common/lib/kibana/services.ts index f4c546a553881..ab06c1be0bf02 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/services.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/services.ts @@ -19,7 +19,10 @@ export class KibanaServices { http, kibanaVersion, config, - }: GlobalServices & { kibanaVersion: string; config: CasesUiConfigType }) { + }: GlobalServices & { + kibanaVersion: string; + config: CasesUiConfigType; + }) { this.services = { http }; this.kibanaVersion = kibanaVersion; this.config = config; diff --git a/x-pack/plugins/cases/public/files/index.test.ts b/x-pack/plugins/cases/public/files/index.test.ts new file mode 100644 index 0000000000000..3db225cf35d6f --- /dev/null +++ b/x-pack/plugins/cases/public/files/index.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MAX_FILE_SIZE } from '../../common/constants'; +import { createMockFilesSetup } from '@kbn/files-plugin/public/mocks'; +import { registerCaseFileKinds } from '.'; +import type { FilesConfig } from './types'; + +describe('ui files index', () => { + describe('registerCaseFileKinds', () => { + const mockFilesSetup = createMockFilesSetup(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('allowedMimeTypes', () => { + const config: FilesConfig = { + allowedMimeTypes: ['abc'], + maxSize: undefined, + }; + + beforeEach(() => { + registerCaseFileKinds(config, mockFilesSetup); + }); + + it('sets cases allowed mime types to abc', () => { + expect(mockFilesSetup.registerFileKind.mock.calls[0][0].allowedMimeTypes).toEqual(['abc']); + }); + + it('sets observability allowed mime types to abc', () => { + expect(mockFilesSetup.registerFileKind.mock.calls[1][0].allowedMimeTypes).toEqual(['abc']); + }); + + it('sets securitySolution allowed mime types to 100 mb', () => { + expect(mockFilesSetup.registerFileKind.mock.calls[2][0].allowedMimeTypes).toEqual(['abc']); + }); + }); + + describe('max file size', () => { + describe('default max file size', () => { + const config: FilesConfig = { + allowedMimeTypes: [], + maxSize: undefined, + }; + + beforeEach(() => { + registerCaseFileKinds(config, mockFilesSetup); + }); + + it('sets cases max file size to 100 mb', () => { + expect(mockFilesSetup.registerFileKind.mock.calls[0][0].maxSizeBytes).toEqual( + MAX_FILE_SIZE + ); + }); + + it('sets observability max file size to 100 mb', () => { + expect(mockFilesSetup.registerFileKind.mock.calls[1][0].maxSizeBytes).toEqual( + MAX_FILE_SIZE + ); + }); + + it('sets securitySolution max file size to 100 mb', () => { + expect(mockFilesSetup.registerFileKind.mock.calls[2][0].maxSizeBytes).toEqual( + MAX_FILE_SIZE + ); + }); + }); + + describe('custom file size', () => { + const config: FilesConfig = { + allowedMimeTypes: [], + maxSize: 5, + }; + + beforeEach(() => { + registerCaseFileKinds(config, mockFilesSetup); + }); + + it('sets cases max file size to 5', () => { + expect(mockFilesSetup.registerFileKind.mock.calls[0][0].maxSizeBytes).toEqual(5); + }); + + it('sets observability max file size to 5', () => { + expect(mockFilesSetup.registerFileKind.mock.calls[1][0].maxSizeBytes).toEqual(5); + }); + + it('sets securitySolution max file size to 5', () => { + expect(mockFilesSetup.registerFileKind.mock.calls[2][0].maxSizeBytes).toEqual(5); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/files/index.ts b/x-pack/plugins/cases/public/files/index.ts index f9490323085af..30a406c788f63 100644 --- a/x-pack/plugins/cases/public/files/index.ts +++ b/x-pack/plugins/cases/public/files/index.ts @@ -7,31 +7,36 @@ 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 { MAX_FILE_SIZE } from '../../common/constants'; +import { MAX_FILE_SIZE, OWNERS } 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'; +import type { CaseFileKinds, FilesConfig } from './types'; -const buildFileKind = (owner: Owner): FileKindBrowser => { +const buildFileKind = (config: FilesConfig, owner: Owner): FileKindBrowser => { return { id: constructFileKindIdByOwner(owner), - allowedMimeTypes: ALLOWED_MIME_TYPES, - maxSizeBytes: MAX_FILE_SIZE, + allowedMimeTypes: config.allowedMimeTypes, + maxSizeBytes: config.maxSize ?? MAX_FILE_SIZE, }; }; /** * The file kind definition for interacting with the file service for the UI */ -const CASES_FILE_KINDS: Record = { - [APP_ID]: buildFileKind(APP_ID), - [SECURITY_SOLUTION_OWNER]: buildFileKind(SECURITY_SOLUTION_OWNER), - [OBSERVABILITY_OWNER]: buildFileKind(OBSERVABILITY_OWNER), +const createFileKinds = (config: FilesConfig): CaseFileKinds => { + const caseFileKinds = new Map(); + + for (const owner of OWNERS) { + caseFileKinds.set(owner, buildFileKind(config, owner)); + } + + return caseFileKinds; }; -export const registerCaseFileKinds = (filesSetupPlugin: FilesSetup) => { - for (const fileKind of Object.values(CASES_FILE_KINDS)) { +export const registerCaseFileKinds = (config: FilesConfig, filesSetupPlugin: FilesSetup) => { + const fileKinds = createFileKinds(config); + + for (const fileKind of fileKinds.values()) { filesSetupPlugin.registerFileKind(fileKind); } }; diff --git a/x-pack/plugins/cases/public/files/types.ts b/x-pack/plugins/cases/public/files/types.ts new file mode 100644 index 0000000000000..d7c4563eb1c06 --- /dev/null +++ b/x-pack/plugins/cases/public/files/types.ts @@ -0,0 +1,14 @@ +/* + * 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 { FileKindBrowser } from '@kbn/shared-ux-file-types'; +import type { Owner } from '../../common/constants/types'; +import type { CasesUiConfigType } from '../containers/types'; + +export type FilesConfig = CasesUiConfigType['files']; + +export type CaseFileKinds = Map; diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts index 4053e35aa6cfc..b1ab9f32700dd 100644 --- a/x-pack/plugins/cases/public/plugin.ts +++ b/x-pack/plugins/cases/public/plugin.ts @@ -53,7 +53,8 @@ export class CasesUiPlugin const externalReferenceAttachmentTypeRegistry = this.externalReferenceAttachmentTypeRegistry; const persistableStateAttachmentTypeRegistry = this.persistableStateAttachmentTypeRegistry; - registerCaseFileKinds(plugins.files); + const config = this.initializerContext.config.get(); + registerCaseFileKinds(config.files, plugins.files); if (plugins.home) { plugins.home.featureCatalogue.register({ @@ -106,7 +107,13 @@ export class CasesUiPlugin public start(core: CoreStart, plugins: CasesPluginStart): CasesUiStart { const config = this.initializerContext.config.get(); - KibanaServices.init({ ...core, ...plugins, kibanaVersion: this.kibanaVersion, config }); + + KibanaServices.init({ + ...core, + ...plugins, + kibanaVersion: this.kibanaVersion, + config, + }); /** * getCasesContextLazy returns a new component each time is being called. To avoid re-renders diff --git a/x-pack/plugins/cases/server/config.test.ts b/x-pack/plugins/cases/server/config.test.ts new file mode 100644 index 0000000000000..54fc42f694bc2 --- /dev/null +++ b/x-pack/plugins/cases/server/config.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigSchema } from './config'; + +describe('config validation', () => { + describe('defaults', () => { + it('sets the defaults correctly', () => { + expect(ConfigSchema.validate({})).toMatchInlineSnapshot(` + Object { + "files": Object { + "allowedMimeTypes": Array [ + "image/aces", + "image/apng", + "image/avci", + "image/avcs", + "image/avif", + "image/bmp", + "image/cgm", + "image/dicom-rle", + "image/dpx", + "image/emf", + "image/example", + "image/fits", + "image/g3fax", + "image/heic", + "image/heic-sequence", + "image/heif", + "image/heif-sequence", + "image/hej2k", + "image/hsj2", + "image/jls", + "image/jp2", + "image/jpeg", + "image/jph", + "image/jphc", + "image/jpm", + "image/jpx", + "image/jxr", + "image/jxrA", + "image/jxrS", + "image/jxs", + "image/jxsc", + "image/jxsi", + "image/jxss", + "image/ktx", + "image/ktx2", + "image/naplps", + "image/png", + "image/prs.btif", + "image/prs.pti", + "image/pwg-raster", + "image/svg+xml", + "image/t38", + "image/tiff", + "image/tiff-fx", + "image/vnd.adobe.photoshop", + "image/vnd.airzip.accelerator.azv", + "image/vnd.cns.inf2", + "image/vnd.dece.graphic", + "image/vnd.djvu", + "image/vnd.dwg", + "image/vnd.dxf", + "image/vnd.dvb.subtitle", + "image/vnd.fastbidsheet", + "image/vnd.fpx", + "image/vnd.fst", + "image/vnd.fujixerox.edmics-mmr", + "image/vnd.fujixerox.edmics-rlc", + "image/vnd.globalgraphics.pgb", + "image/vnd.microsoft.icon", + "image/vnd.mix", + "image/vnd.ms-modi", + "image/vnd.mozilla.apng", + "image/vnd.net-fpx", + "image/vnd.pco.b16", + "image/vnd.radiance", + "image/vnd.sealed.png", + "image/vnd.sealedmedia.softseal.gif", + "image/vnd.sealedmedia.softseal.jpg", + "image/vnd.svf", + "image/vnd.tencent.tap", + "image/vnd.valve.source.texture", + "image/vnd.wap.wbmp", + "image/vnd.xiff", + "image/vnd.zbrush.pcx", + "image/webp", + "image/wmf", + "text/plain", + "text/csv", + "text/json", + "application/json", + "application/zip", + "application/gzip", + "application/x-bzip", + "application/x-bzip2", + "application/x-7z-compressed", + "application/x-tar", + "application/pdf", + ], + }, + "markdownPlugins": Object { + "lens": true, + }, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/config.ts b/x-pack/plugins/cases/server/config.ts index dc110412d1733..c2daeb73b03de 100644 --- a/x-pack/plugins/cases/server/config.ts +++ b/x-pack/plugins/cases/server/config.ts @@ -7,11 +7,19 @@ import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; +import { ALLOWED_MIME_TYPES } from '../common/constants/mime_types'; export const ConfigSchema = schema.object({ markdownPlugins: schema.object({ lens: schema.boolean({ defaultValue: true }), }), + files: schema.object({ + allowedMimeTypes: schema.arrayOf(schema.string({ minLength: 1 }), { + defaultValue: ALLOWED_MIME_TYPES, + }), + // intentionally not setting a default here so that we can determine if the user set it + maxSize: schema.maybe(schema.number({ min: 0 })), + }), }); export type ConfigType = TypeOf; diff --git a/x-pack/plugins/cases/server/files/index.test.ts b/x-pack/plugins/cases/server/files/index.test.ts new file mode 100644 index 0000000000000..29c58c8b1d685 --- /dev/null +++ b/x-pack/plugins/cases/server/files/index.test.ts @@ -0,0 +1,720 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MAX_FILE_SIZE, MAX_IMAGE_FILE_SIZE } from '../../common/constants'; +import { createFilesSetupMock } from '@kbn/files-plugin/server/mocks'; +import type { FileJSON } from '@kbn/shared-ux-file-types'; +import { createMaxCallback, registerCaseFileKinds } from '.'; +import { ConfigSchema } from '../config'; + +describe('server files', () => { + describe('registerCaseFileKinds', () => { + const mockFilesSetup = createFilesSetupMock(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('file sizes', () => { + it('sets the max image file size to 10 mb', () => { + const schema = ConfigSchema.validate({}); + + const maxFileSizeFn = createMaxCallback(schema.files); + + expect( + maxFileSizeFn({ + mimeType: 'image/png', + } as unknown as FileJSON) + ).toEqual(MAX_IMAGE_FILE_SIZE); + }); + + it('sets the max file size to 1 when an image is passed but the configuration was specified', () => { + const schema = ConfigSchema.validate({ + files: { + maxSize: 1, + }, + }); + + const maxFileSizeFn = createMaxCallback(schema.files); + + expect( + maxFileSizeFn({ + mimeType: 'image/png', + } as unknown as FileJSON) + ).toEqual(1); + }); + + it('sets the max non-image file size to default 100 mb', () => { + const schema = ConfigSchema.validate({}); + + const maxFileSizeFn = createMaxCallback(schema.files); + + expect( + maxFileSizeFn({ + mimeType: 'text/plain', + } as unknown as FileJSON) + ).toEqual(MAX_FILE_SIZE); + }); + + it('returns 100 mb when images are not allowed in the mime type and an image is passed', () => { + const schemaNoImages = ConfigSchema.validate({ + files: { + allowedMimeTypes: ['abc/123'], + }, + }); + + const maxFn = createMaxCallback(schemaNoImages.files); + + expect( + maxFn({ + mimeType: 'image/png', + } as unknown as FileJSON) + ).toEqual(MAX_FILE_SIZE); + }); + + it('returns 100 mb when the mime type is not recognized', () => { + const schemaNoImages = ConfigSchema.validate({ + files: { + allowedMimeTypes: ['abc/123'], + }, + }); + + const maxFn = createMaxCallback(schemaNoImages.files); + + expect( + maxFn({ + mimeType: 'hi/bye', + } as unknown as FileJSON) + ).toEqual(MAX_FILE_SIZE); + }); + + it('returns 100 mb when the mime type is undefined', () => { + const schemaNoImages = ConfigSchema.validate({ + files: { + allowedMimeTypes: ['abc/123'], + }, + }); + + const maxFn = createMaxCallback(schemaNoImages.files); + + expect( + maxFn({ + mimeType: undefined, + } as unknown as FileJSON) + ).toEqual(MAX_FILE_SIZE); + }); + }); + + describe('allowed mime types', () => { + describe('image png', () => { + const schema = ConfigSchema.validate({ files: { allowedMimeTypes: ['image/png'] } }); + + it('sets the cases file kind allowed mime types to only image png', () => { + registerCaseFileKinds(schema.files, mockFilesSetup); + + expect(mockFilesSetup.registerFileKind.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "allowedMimeTypes": Array [ + "image/png", + ], + "http": Object { + "create": Object { + "tags": Array [ + "access:casesFilesCasesCreate", + ], + }, + "download": Object { + "tags": Array [ + "access:casesFilesCasesRead", + ], + }, + "getById": Object { + "tags": Array [ + "access:casesFilesCasesRead", + ], + }, + "list": Object { + "tags": Array [ + "access:casesFilesCasesRead", + ], + }, + }, + "id": "casesFilesCases", + "maxSizeBytes": [Function], + }, + ] + `); + }); + + it('sets the observability file kind allowed mime types to only image png', () => { + registerCaseFileKinds(schema.files, mockFilesSetup); + + expect(mockFilesSetup.registerFileKind.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + Object { + "allowedMimeTypes": Array [ + "image/png", + ], + "http": Object { + "create": Object { + "tags": Array [ + "access:observabilityFilesCasesCreate", + ], + }, + "download": Object { + "tags": Array [ + "access:observabilityFilesCasesRead", + ], + }, + "getById": Object { + "tags": Array [ + "access:observabilityFilesCasesRead", + ], + }, + "list": Object { + "tags": Array [ + "access:observabilityFilesCasesRead", + ], + }, + }, + "id": "observabilityFilesCases", + "maxSizeBytes": [Function], + }, + ] + `); + }); + + it('sets the security solution file kind allowed mime types to only image png', () => { + registerCaseFileKinds(schema.files, mockFilesSetup); + + expect(mockFilesSetup.registerFileKind.mock.calls[2]).toMatchInlineSnapshot(` + Array [ + Object { + "allowedMimeTypes": Array [ + "image/png", + ], + "http": Object { + "create": Object { + "tags": Array [ + "access:securitySolutionFilesCasesCreate", + ], + }, + "download": Object { + "tags": Array [ + "access:securitySolutionFilesCasesRead", + ], + }, + "getById": Object { + "tags": Array [ + "access:securitySolutionFilesCasesRead", + ], + }, + "list": Object { + "tags": Array [ + "access:securitySolutionFilesCasesRead", + ], + }, + }, + "id": "securitySolutionFilesCases", + "maxSizeBytes": [Function], + }, + ] + `); + }); + }); + + describe('no mime types', () => { + const schema = ConfigSchema.validate({ files: { allowedMimeTypes: [] } }); + + it('sets the cases file kind allowed mime types to an empty array', () => { + registerCaseFileKinds(schema.files, mockFilesSetup); + + expect(mockFilesSetup.registerFileKind.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "allowedMimeTypes": Array [], + "http": Object { + "create": Object { + "tags": Array [ + "access:casesFilesCasesCreate", + ], + }, + "download": Object { + "tags": Array [ + "access:casesFilesCasesRead", + ], + }, + "getById": Object { + "tags": Array [ + "access:casesFilesCasesRead", + ], + }, + "list": Object { + "tags": Array [ + "access:casesFilesCasesRead", + ], + }, + }, + "id": "casesFilesCases", + "maxSizeBytes": [Function], + }, + ] + `); + }); + + it('sets the observability file kind allowed mime types to an empty array', () => { + registerCaseFileKinds(schema.files, mockFilesSetup); + + expect(mockFilesSetup.registerFileKind.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + Object { + "allowedMimeTypes": Array [], + "http": Object { + "create": Object { + "tags": Array [ + "access:observabilityFilesCasesCreate", + ], + }, + "download": Object { + "tags": Array [ + "access:observabilityFilesCasesRead", + ], + }, + "getById": Object { + "tags": Array [ + "access:observabilityFilesCasesRead", + ], + }, + "list": Object { + "tags": Array [ + "access:observabilityFilesCasesRead", + ], + }, + }, + "id": "observabilityFilesCases", + "maxSizeBytes": [Function], + }, + ] + `); + }); + + it('sets the security solution file kind allowed mime types to an empty array', () => { + registerCaseFileKinds(schema.files, mockFilesSetup); + + expect(mockFilesSetup.registerFileKind.mock.calls[2]).toMatchInlineSnapshot(` + Array [ + Object { + "allowedMimeTypes": Array [], + "http": Object { + "create": Object { + "tags": Array [ + "access:securitySolutionFilesCasesCreate", + ], + }, + "download": Object { + "tags": Array [ + "access:securitySolutionFilesCasesRead", + ], + }, + "getById": Object { + "tags": Array [ + "access:securitySolutionFilesCasesRead", + ], + }, + "list": Object { + "tags": Array [ + "access:securitySolutionFilesCasesRead", + ], + }, + }, + "id": "securitySolutionFilesCases", + "maxSizeBytes": [Function], + }, + ] + `); + }); + }); + }); + + describe('defaults', () => { + const defaultSchema = ConfigSchema.validate({}); + + it('sets the cases file kind with defaults correctly', () => { + registerCaseFileKinds(defaultSchema.files, mockFilesSetup); + + expect(mockFilesSetup.registerFileKind.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "allowedMimeTypes": Array [ + "image/aces", + "image/apng", + "image/avci", + "image/avcs", + "image/avif", + "image/bmp", + "image/cgm", + "image/dicom-rle", + "image/dpx", + "image/emf", + "image/example", + "image/fits", + "image/g3fax", + "image/heic", + "image/heic-sequence", + "image/heif", + "image/heif-sequence", + "image/hej2k", + "image/hsj2", + "image/jls", + "image/jp2", + "image/jpeg", + "image/jph", + "image/jphc", + "image/jpm", + "image/jpx", + "image/jxr", + "image/jxrA", + "image/jxrS", + "image/jxs", + "image/jxsc", + "image/jxsi", + "image/jxss", + "image/ktx", + "image/ktx2", + "image/naplps", + "image/png", + "image/prs.btif", + "image/prs.pti", + "image/pwg-raster", + "image/svg+xml", + "image/t38", + "image/tiff", + "image/tiff-fx", + "image/vnd.adobe.photoshop", + "image/vnd.airzip.accelerator.azv", + "image/vnd.cns.inf2", + "image/vnd.dece.graphic", + "image/vnd.djvu", + "image/vnd.dwg", + "image/vnd.dxf", + "image/vnd.dvb.subtitle", + "image/vnd.fastbidsheet", + "image/vnd.fpx", + "image/vnd.fst", + "image/vnd.fujixerox.edmics-mmr", + "image/vnd.fujixerox.edmics-rlc", + "image/vnd.globalgraphics.pgb", + "image/vnd.microsoft.icon", + "image/vnd.mix", + "image/vnd.ms-modi", + "image/vnd.mozilla.apng", + "image/vnd.net-fpx", + "image/vnd.pco.b16", + "image/vnd.radiance", + "image/vnd.sealed.png", + "image/vnd.sealedmedia.softseal.gif", + "image/vnd.sealedmedia.softseal.jpg", + "image/vnd.svf", + "image/vnd.tencent.tap", + "image/vnd.valve.source.texture", + "image/vnd.wap.wbmp", + "image/vnd.xiff", + "image/vnd.zbrush.pcx", + "image/webp", + "image/wmf", + "text/plain", + "text/csv", + "text/json", + "application/json", + "application/zip", + "application/gzip", + "application/x-bzip", + "application/x-bzip2", + "application/x-7z-compressed", + "application/x-tar", + "application/pdf", + ], + "http": Object { + "create": Object { + "tags": Array [ + "access:casesFilesCasesCreate", + ], + }, + "download": Object { + "tags": Array [ + "access:casesFilesCasesRead", + ], + }, + "getById": Object { + "tags": Array [ + "access:casesFilesCasesRead", + ], + }, + "list": Object { + "tags": Array [ + "access:casesFilesCasesRead", + ], + }, + }, + "id": "casesFilesCases", + "maxSizeBytes": [Function], + }, + ] + `); + }); + + it('sets the observability file kind with defaults correctly', () => { + registerCaseFileKinds(defaultSchema.files, mockFilesSetup); + + expect(mockFilesSetup.registerFileKind.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + Object { + "allowedMimeTypes": Array [ + "image/aces", + "image/apng", + "image/avci", + "image/avcs", + "image/avif", + "image/bmp", + "image/cgm", + "image/dicom-rle", + "image/dpx", + "image/emf", + "image/example", + "image/fits", + "image/g3fax", + "image/heic", + "image/heic-sequence", + "image/heif", + "image/heif-sequence", + "image/hej2k", + "image/hsj2", + "image/jls", + "image/jp2", + "image/jpeg", + "image/jph", + "image/jphc", + "image/jpm", + "image/jpx", + "image/jxr", + "image/jxrA", + "image/jxrS", + "image/jxs", + "image/jxsc", + "image/jxsi", + "image/jxss", + "image/ktx", + "image/ktx2", + "image/naplps", + "image/png", + "image/prs.btif", + "image/prs.pti", + "image/pwg-raster", + "image/svg+xml", + "image/t38", + "image/tiff", + "image/tiff-fx", + "image/vnd.adobe.photoshop", + "image/vnd.airzip.accelerator.azv", + "image/vnd.cns.inf2", + "image/vnd.dece.graphic", + "image/vnd.djvu", + "image/vnd.dwg", + "image/vnd.dxf", + "image/vnd.dvb.subtitle", + "image/vnd.fastbidsheet", + "image/vnd.fpx", + "image/vnd.fst", + "image/vnd.fujixerox.edmics-mmr", + "image/vnd.fujixerox.edmics-rlc", + "image/vnd.globalgraphics.pgb", + "image/vnd.microsoft.icon", + "image/vnd.mix", + "image/vnd.ms-modi", + "image/vnd.mozilla.apng", + "image/vnd.net-fpx", + "image/vnd.pco.b16", + "image/vnd.radiance", + "image/vnd.sealed.png", + "image/vnd.sealedmedia.softseal.gif", + "image/vnd.sealedmedia.softseal.jpg", + "image/vnd.svf", + "image/vnd.tencent.tap", + "image/vnd.valve.source.texture", + "image/vnd.wap.wbmp", + "image/vnd.xiff", + "image/vnd.zbrush.pcx", + "image/webp", + "image/wmf", + "text/plain", + "text/csv", + "text/json", + "application/json", + "application/zip", + "application/gzip", + "application/x-bzip", + "application/x-bzip2", + "application/x-7z-compressed", + "application/x-tar", + "application/pdf", + ], + "http": Object { + "create": Object { + "tags": Array [ + "access:observabilityFilesCasesCreate", + ], + }, + "download": Object { + "tags": Array [ + "access:observabilityFilesCasesRead", + ], + }, + "getById": Object { + "tags": Array [ + "access:observabilityFilesCasesRead", + ], + }, + "list": Object { + "tags": Array [ + "access:observabilityFilesCasesRead", + ], + }, + }, + "id": "observabilityFilesCases", + "maxSizeBytes": [Function], + }, + ] + `); + }); + + it('sets the securitySolution file kind with defaults correctly', () => { + registerCaseFileKinds(defaultSchema.files, mockFilesSetup); + + expect(mockFilesSetup.registerFileKind.mock.calls[2]).toMatchInlineSnapshot(` + Array [ + Object { + "allowedMimeTypes": Array [ + "image/aces", + "image/apng", + "image/avci", + "image/avcs", + "image/avif", + "image/bmp", + "image/cgm", + "image/dicom-rle", + "image/dpx", + "image/emf", + "image/example", + "image/fits", + "image/g3fax", + "image/heic", + "image/heic-sequence", + "image/heif", + "image/heif-sequence", + "image/hej2k", + "image/hsj2", + "image/jls", + "image/jp2", + "image/jpeg", + "image/jph", + "image/jphc", + "image/jpm", + "image/jpx", + "image/jxr", + "image/jxrA", + "image/jxrS", + "image/jxs", + "image/jxsc", + "image/jxsi", + "image/jxss", + "image/ktx", + "image/ktx2", + "image/naplps", + "image/png", + "image/prs.btif", + "image/prs.pti", + "image/pwg-raster", + "image/svg+xml", + "image/t38", + "image/tiff", + "image/tiff-fx", + "image/vnd.adobe.photoshop", + "image/vnd.airzip.accelerator.azv", + "image/vnd.cns.inf2", + "image/vnd.dece.graphic", + "image/vnd.djvu", + "image/vnd.dwg", + "image/vnd.dxf", + "image/vnd.dvb.subtitle", + "image/vnd.fastbidsheet", + "image/vnd.fpx", + "image/vnd.fst", + "image/vnd.fujixerox.edmics-mmr", + "image/vnd.fujixerox.edmics-rlc", + "image/vnd.globalgraphics.pgb", + "image/vnd.microsoft.icon", + "image/vnd.mix", + "image/vnd.ms-modi", + "image/vnd.mozilla.apng", + "image/vnd.net-fpx", + "image/vnd.pco.b16", + "image/vnd.radiance", + "image/vnd.sealed.png", + "image/vnd.sealedmedia.softseal.gif", + "image/vnd.sealedmedia.softseal.jpg", + "image/vnd.svf", + "image/vnd.tencent.tap", + "image/vnd.valve.source.texture", + "image/vnd.wap.wbmp", + "image/vnd.xiff", + "image/vnd.zbrush.pcx", + "image/webp", + "image/wmf", + "text/plain", + "text/csv", + "text/json", + "application/json", + "application/zip", + "application/gzip", + "application/x-bzip", + "application/x-bzip2", + "application/x-7z-compressed", + "application/x-tar", + "application/pdf", + ], + "http": Object { + "create": Object { + "tags": Array [ + "access:securitySolutionFilesCasesCreate", + ], + }, + "download": Object { + "tags": Array [ + "access:securitySolutionFilesCasesRead", + ], + }, + "getById": Object { + "tags": Array [ + "access:securitySolutionFilesCasesRead", + ], + }, + "list": Object { + "tags": Array [ + "access:securitySolutionFilesCasesRead", + ], + }, + }, + "id": "securitySolutionFilesCases", + "maxSizeBytes": [Function], + }, + ] + `); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/files/index.ts b/x-pack/plugins/cases/server/files/index.ts index 3c0b35c193f25..af8f0c1440277 100644 --- a/x-pack/plugins/cases/server/files/index.ts +++ b/x-pack/plugins/cases/server/files/index.ts @@ -10,20 +10,22 @@ import type { FilesSetup } from '@kbn/files-plugin/server'; import { APP_ID, MAX_FILE_SIZE, + MAX_IMAGE_FILE_SIZE, OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER, } from '../../common/constants'; 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 { IMAGE_MIME_TYPES } from '../../common/constants/mime_types'; +import type { FilesConfig } from './types'; import { constructFileKindIdByOwner, constructFilesHttpOperationTag } from '../../common/files'; -const buildFileKind = (owner: Owner): FileKind => { +const buildFileKind = (config: FilesConfig, owner: Owner): FileKind => { return { id: constructFileKindIdByOwner(owner), http: fileKindHttpTags(owner), - maxSizeBytes, - allowedMimeTypes: ALLOWED_MIME_TYPES, + maxSizeBytes: createMaxCallback(config), + allowedMimeTypes: config.allowedMimeTypes, }; }; @@ -44,27 +46,44 @@ const buildTag = (owner: Owner, operation: HttpApiTagOperation) => { }; }; -const MAX_IMAGE_FILE_SIZE = 10 * 1024 * 1024; // 10 MiB +export const createMaxCallback = + (config: FilesConfig) => + (file: FileJSON): number => { + // if the user set a max size, always return that + if (config.maxSize != null) { + return config.maxSize; + } -const maxSizeBytes = (file: FileJSON): number => { - if (file.mimeType != null && IMAGE_MIME_TYPES.has(file.mimeType)) { - return MAX_IMAGE_FILE_SIZE; - } + const allowedMimeTypesSet = new Set(config.allowedMimeTypes); - return MAX_FILE_SIZE; -}; + // if we have the mime type for the file and it exists within the allowed types and it is an image then return the + // image size + if ( + file.mimeType != null && + allowedMimeTypesSet.has(file.mimeType) && + IMAGE_MIME_TYPES.has(file.mimeType) + ) { + return MAX_IMAGE_FILE_SIZE; + } + + return MAX_FILE_SIZE; + }; /** * The file kind definition for interacting with the file service for the backend */ -const CASES_FILE_KINDS: Record = { - [APP_ID]: buildFileKind(APP_ID), - [SECURITY_SOLUTION_OWNER]: buildFileKind(SECURITY_SOLUTION_OWNER), - [OBSERVABILITY_OWNER]: buildFileKind(OBSERVABILITY_OWNER), +const createFileKinds = (config: FilesConfig): Record => { + return { + [APP_ID]: buildFileKind(config, APP_ID), + [OBSERVABILITY_OWNER]: buildFileKind(config, OBSERVABILITY_OWNER), + [SECURITY_SOLUTION_OWNER]: buildFileKind(config, SECURITY_SOLUTION_OWNER), + }; }; -export const registerCaseFileKinds = (filesSetupPlugin: FilesSetup) => { - for (const fileKind of Object.values(CASES_FILE_KINDS)) { +export const registerCaseFileKinds = (config: FilesConfig, filesSetupPlugin: FilesSetup) => { + const fileKinds = createFileKinds(config); + + for (const fileKind of Object.values(fileKinds)) { filesSetupPlugin.registerFileKind(fileKind); } }; diff --git a/x-pack/plugins/cases/server/files/types.ts b/x-pack/plugins/cases/server/files/types.ts new file mode 100644 index 0000000000000..5c7c1693e5a04 --- /dev/null +++ b/x-pack/plugins/cases/server/files/types.ts @@ -0,0 +1,10 @@ +/* + * 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 { ConfigType } from '../config'; + +export type FilesConfig = ConfigType['files']; diff --git a/x-pack/plugins/cases/server/index.ts b/x-pack/plugins/cases/server/index.ts index d039d7ebd41a1..62748905f6b82 100644 --- a/x-pack/plugins/cases/server/index.ts +++ b/x-pack/plugins/cases/server/index.ts @@ -15,6 +15,7 @@ export const config: PluginConfigDescriptor = { schema: ConfigSchema, exposeToBrowser: { markdownPlugins: true, + files: { maxSize: true, allowedMimeTypes: true }, }, deprecations: ({ renameFromRoot }) => [ renameFromRoot('xpack.case.enabled', 'xpack.cases.enabled', { level: 'critical' }), diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 21eb63bde3ff9..6fcf27e6410e6 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -59,6 +59,7 @@ import { UserProfileService } from './services'; import { LICENSING_CASE_ASSIGNMENT_FEATURE } from './common/constants'; import { registerInternalAttachments } from './internal_attachments'; import { registerCaseFileKinds } from './files'; +import type { ConfigType } from './config'; export interface PluginsSetup { actions: ActionsPluginSetup; @@ -84,6 +85,7 @@ export interface PluginsStart { } export class CasePlugin { + private readonly caseConfig: ConfigType; private readonly logger: Logger; private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; private clientFactory: CasesClientFactory; @@ -94,6 +96,7 @@ export class CasePlugin { private userProfileService: UserProfileService; constructor(private readonly initializerContext: PluginInitializerContext) { + this.caseConfig = initializerContext.config.get(); this.kibanaVersion = initializerContext.env.packageInfo.version; this.logger = this.initializerContext.logger.get(); this.clientFactory = new CasesClientFactory(this.logger); @@ -110,7 +113,7 @@ export class CasePlugin { ); registerInternalAttachments(this.externalReferenceAttachmentTypeRegistry); - registerCaseFileKinds(plugins.files); + registerCaseFileKinds(this.caseConfig.files, plugins.files); this.securityPluginSetup = plugins.security; this.lensEmbeddableFactory = plugins.lens.lensEmbeddableFactory;