From 4fbf9c7caa836a609af35b391f427117c59ff2e5 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 9 Jan 2020 07:22:31 -0700 Subject: [PATCH] [SIEM] [Case] Case workflow api schema (#51535) (#54304) --- .../lib/case/saved_object_mappings_temp.ts | 91 +++++++++ x-pack/plugins/case/README.md | 9 + x-pack/plugins/case/kibana.json | 9 + x-pack/plugins/case/server/config.ts | 15 ++ x-pack/plugins/case/server/constants.ts | 8 + x-pack/plugins/case/server/index.ts | 14 ++ x-pack/plugins/case/server/plugin.ts | 63 ++++++ .../routes/api/__fixtures__/authc_mock.ts | 35 ++++ .../__fixtures__/create_mock_so_repository.ts | 77 +++++++ .../server/routes/api/__fixtures__/index.ts | 11 + .../routes/api/__fixtures__/mock_router.ts | 34 ++++ .../api/__fixtures__/mock_saved_objects.ts | 143 +++++++++++++ .../routes/api/__fixtures__/route_contexts.ts | 17 ++ .../routes/api/__tests__/delete_case.test.ts | 83 ++++++++ .../api/__tests__/delete_comment.test.ts | 53 +++++ .../api/__tests__/get_all_cases.test.ts | 34 ++++ .../routes/api/__tests__/get_case.test.ts | 101 +++++++++ .../routes/api/__tests__/get_comment.test.ts | 51 +++++ .../routes/api/__tests__/post_case.test.ts | 82 ++++++++ .../routes/api/__tests__/post_comment.test.ts | 97 +++++++++ .../routes/api/__tests__/update_case.test.ts | 59 ++++++ .../api/__tests__/update_comment.test.ts | 59 ++++++ .../case/server/routes/api/delete_case.ts | 56 +++++ .../case/server/routes/api/delete_comment.ts | 34 ++++ .../routes/api/get_all_case_comments.ts | 33 +++ .../case/server/routes/api/get_all_cases.ts | 27 +++ .../case/server/routes/api/get_case.ts | 49 +++++ .../case/server/routes/api/get_comment.ts | 33 +++ .../plugins/case/server/routes/api/index.ts | 36 ++++ .../case/server/routes/api/post_case.ts | 40 ++++ .../case/server/routes/api/post_comment.ts | 62 ++++++ .../plugins/case/server/routes/api/schema.ts | 44 ++++ .../plugins/case/server/routes/api/types.ts | 36 ++++ .../case/server/routes/api/update_case.ts | 36 ++++ .../case/server/routes/api/update_comment.ts | 36 ++++ .../plugins/case/server/routes/api/utils.ts | 48 +++++ x-pack/plugins/case/server/services/index.ts | 192 ++++++++++++++++++ x-pack/plugins/security/server/index.ts | 2 + 38 files changed, 1909 insertions(+) create mode 100644 x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts create mode 100644 x-pack/plugins/case/README.md create mode 100644 x-pack/plugins/case/kibana.json create mode 100644 x-pack/plugins/case/server/config.ts create mode 100644 x-pack/plugins/case/server/constants.ts create mode 100644 x-pack/plugins/case/server/index.ts create mode 100644 x-pack/plugins/case/server/plugin.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/index.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts create mode 100644 x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts create mode 100644 x-pack/plugins/case/server/routes/api/delete_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/delete_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_all_case_comments.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_all_cases.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/get_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/index.ts create mode 100644 x-pack/plugins/case/server/routes/api/post_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/post_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/schema.ts create mode 100644 x-pack/plugins/case/server/routes/api/types.ts create mode 100644 x-pack/plugins/case/server/routes/api/update_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/update_comment.ts create mode 100644 x-pack/plugins/case/server/routes/api/utils.ts create mode 100644 x-pack/plugins/case/server/services/index.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts new file mode 100644 index 00000000000000..bd73805600a338 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/case/saved_object_mappings_temp.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable @typescript-eslint/camelcase */ +import { + NewCaseFormatted, + NewCommentFormatted, +} from '../../../../../../../x-pack/plugins/case/server'; +import { ElasticsearchMappingOf } from '../../utils/typed_elasticsearch_mappings'; + +// Temporary file to write mappings for case +// while Saved Object Mappings API is programmed for the NP +// See: https://github.com/elastic/kibana/issues/50309 + +export const caseSavedObjectType = 'case-workflow'; +export const caseCommentSavedObjectType = 'case-workflow-comment'; + +export const caseSavedObjectMappings: { + [caseSavedObjectType]: ElasticsearchMappingOf; +} = { + [caseSavedObjectType]: { + properties: { + assignees: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + created_at: { + type: 'date', + }, + description: { + type: 'text', + }, + title: { + type: 'keyword', + }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + state: { + type: 'keyword', + }, + tags: { + type: 'keyword', + }, + case_type: { + type: 'keyword', + }, + }, + }, +}; + +export const caseCommentSavedObjectMappings: { + [caseCommentSavedObjectType]: ElasticsearchMappingOf; +} = { + [caseCommentSavedObjectType]: { + properties: { + comment: { + type: 'text', + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + full_name: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/README.md b/x-pack/plugins/case/README.md new file mode 100644 index 00000000000000..c0acb878352079 --- /dev/null +++ b/x-pack/plugins/case/README.md @@ -0,0 +1,9 @@ +# Case Workflow + +*Experimental Feature* + +Elastic is developing a Case Management Workflow. Follow our progress: + +- [Case API Documentation](https://documenter.getpostman.com/view/172706/SW7c2SuF?version=latest) +- [Github Meta](https://github.com/elastic/kibana/issues/50103) + diff --git a/x-pack/plugins/case/kibana.json b/x-pack/plugins/case/kibana.json new file mode 100644 index 00000000000000..23e3cc789ad3b3 --- /dev/null +++ b/x-pack/plugins/case/kibana.json @@ -0,0 +1,9 @@ +{ + "configPath": ["xpack", "case"], + "id": "case", + "kibanaVersion": "kibana", + "requiredPlugins": ["security"], + "server": true, + "ui": false, + "version": "8.0.0" +} diff --git a/x-pack/plugins/case/server/config.ts b/x-pack/plugins/case/server/config.ts new file mode 100644 index 00000000000000..a7cb117198f9b7 --- /dev/null +++ b/x-pack/plugins/case/server/config.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), + indexPattern: schema.string({ defaultValue: '.case-test-2' }), + secret: schema.string({ defaultValue: 'Cool secret huh?' }), +}); + +export type ConfigType = TypeOf; diff --git a/x-pack/plugins/case/server/constants.ts b/x-pack/plugins/case/server/constants.ts new file mode 100644 index 00000000000000..276dcd135254a3 --- /dev/null +++ b/x-pack/plugins/case/server/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const CASE_SAVED_OBJECT = 'case-workflow'; +export const CASE_COMMENT_SAVED_OBJECT = 'case-workflow-comment'; diff --git a/x-pack/plugins/case/server/index.ts b/x-pack/plugins/case/server/index.ts new file mode 100644 index 00000000000000..3963debea97955 --- /dev/null +++ b/x-pack/plugins/case/server/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { ConfigSchema } from './config'; +import { CasePlugin } from './plugin'; +export { NewCaseFormatted, NewCommentFormatted } from './routes/api/types'; + +export const config = { schema: ConfigSchema }; +export const plugin = (initializerContext: PluginInitializerContext) => + new CasePlugin(initializerContext); diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts new file mode 100644 index 00000000000000..c52461cade0586 --- /dev/null +++ b/x-pack/plugins/case/server/plugin.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first, map } from 'rxjs/operators'; +import { CoreSetup, Logger, PluginInitializerContext } from 'kibana/server'; +import { ConfigType } from './config'; +import { initCaseApi } from './routes/api'; +import { CaseService } from './services'; +import { PluginSetupContract as SecurityPluginSetup } from '../../security/server'; + +function createConfig$(context: PluginInitializerContext) { + return context.config.create().pipe(map(config => config)); +} + +export interface PluginsSetup { + security: SecurityPluginSetup; +} + +export class CasePlugin { + private readonly log: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.log = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup, plugins: PluginsSetup) { + const config = await createConfig$(this.initializerContext) + .pipe(first()) + .toPromise(); + + if (!config.enabled) { + return; + } + const service = new CaseService(this.log); + + this.log.debug( + `Setting up Case Workflow with core contract [${Object.keys( + core + )}] and plugins [${Object.keys(plugins)}]` + ); + + const caseService = await service.setup({ + authentication: plugins.security.authc, + }); + + const router = core.http.createRouter(); + initCaseApi({ + caseService, + router, + }); + } + + public start() { + this.log.debug(`Starting Case Workflow`); + } + + public stop() { + this.log.debug(`Stopping Case Workflow`); + } +} diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts new file mode 100644 index 00000000000000..94ce9627b9ac64 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Authentication } from '../../../../../security/server'; + +const getCurrentUser = jest.fn().mockReturnValue({ + username: 'awesome', + full_name: 'Awesome D00d', +}); +const getCurrentUserThrow = jest.fn().mockImplementation(() => { + throw new Error('Bad User - the user is not authenticated'); +}); + +export const authenticationMock = { + create: (): jest.Mocked => ({ + login: jest.fn(), + createAPIKey: jest.fn(), + getCurrentUser, + invalidateAPIKey: jest.fn(), + isAuthenticated: jest.fn(), + logout: jest.fn(), + getSessionInfo: jest.fn(), + }), + createInvalid: (): jest.Mocked => ({ + login: jest.fn(), + createAPIKey: jest.fn(), + getCurrentUser: getCurrentUserThrow, + invalidateAPIKey: jest.fn(), + isAuthenticated: jest.fn(), + logout: jest.fn(), + getSessionInfo: jest.fn(), + }), +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts new file mode 100644 index 00000000000000..360c6de67b2a81 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../../constants'; + +export const createMockSavedObjectsRepository = (savedObject: any[] = []) => { + const mockSavedObjectsClientContract = ({ + get: jest.fn((type, id) => { + const result = savedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + }), + find: jest.fn(findArgs => { + if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + return { + total: savedObject.length, + saved_objects: savedObject, + }; + }), + create: jest.fn((type, attributes, references) => { + if (attributes.description === 'Throw an error' || attributes.comment === 'Throw an error') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + if (type === CASE_COMMENT_SAVED_OBJECT) { + return { + type, + id: 'mock-comment', + attributes, + ...references, + updated_at: '2019-12-02T22:48:08.327Z', + version: 'WzksMV0=', + }; + } + return { + type, + id: 'mock-it', + attributes, + references: [], + updated_at: '2019-12-02T22:48:08.327Z', + version: 'WzksMV0=', + }; + }), + update: jest.fn((type, id, attributes) => { + if (!savedObject.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return { + id, + type, + updated_at: '2019-11-22T22:50:55.191Z', + version: 'WzE3LDFd', + attributes, + }; + }), + delete: jest.fn((type: string, id: string) => { + const result = savedObject.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + if (type === 'case-workflow-comment' && id === 'bad-guy') { + throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); + } + return {}; + }), + deleteByNamespace: jest.fn(), + } as unknown) as jest.Mocked; + + return mockSavedObjectsClientContract; +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts new file mode 100644 index 00000000000000..e1fec2d6b229ce --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { mockCases, mockCasesErrorTriggerData, mockCaseComments } from './mock_saved_objects'; +export { createMockSavedObjectsRepository } from './create_mock_so_repository'; +export { createRouteContext } from './route_contexts'; +export { authenticationMock } from './authc_mock'; +export { createRoute } from './mock_router'; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts new file mode 100644 index 00000000000000..84889c3ac49be1 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'kibana/server'; +import { loggingServiceMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; +import { CaseService } from '../../../services'; +import { authenticationMock } from '../__fixtures__'; +import { RouteDeps } from '../index'; + +export const createRoute = async ( + api: (deps: RouteDeps) => void, + method: 'get' | 'post' | 'delete', + badAuth = false +) => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + + const log = loggingServiceMock.create().get('case'); + + const service = new CaseService(log); + const caseService = await service.setup({ + authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), + }); + + api({ + router, + caseService, + }); + + return router[method].mock.calls[0][1]; +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts new file mode 100644 index 00000000000000..d59f0977e69934 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mockCases = [ + { + type: 'case-workflow', + id: 'mock-id-1', + attributes: { + created_at: 1574718888885, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T21:54:48.952Z', + version: 'WzAsMV0=', + }, + { + type: 'case-workflow', + id: 'mock-id-2', + attributes: { + created_at: 1574721120834, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'Oh no, a bad meanie destroying data!', + title: 'Damaging Data Destruction Detected', + state: 'open', + tags: ['Data Destruction'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T22:32:00.900Z', + version: 'WzQsMV0=', + }, + { + type: 'case-workflow', + id: 'mock-id-3', + attributes: { + created_at: 1574721137881, + created_by: { + full_name: null, + username: 'elastic', + }, + description: 'Oh no, a bad meanie going LOLBins all over the place!', + title: 'Another bad one', + state: 'open', + tags: ['LOLBins'], + case_type: 'security', + assignees: [], + }, + references: [], + updated_at: '2019-11-25T22:32:17.947Z', + version: 'WzUsMV0=', + }, +]; + +export const mockCasesErrorTriggerData = [ + { + id: 'valid-id', + }, + { + id: 'bad-guy', + }, +]; + +export const mockCaseComments = [ + { + type: 'case-workflow-comment', + id: 'mock-comment-1', + attributes: { + comment: 'Wow, good luck catching that bad meanie!', + created_at: 1574718900112, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-1', + }, + ], + updated_at: '2019-11-25T21:55:00.177Z', + version: 'WzEsMV0=', + }, + { + type: 'case-workflow-comment', + id: 'mock-comment-2', + attributes: { + comment: 'Well I decided to update my comment. So what? Deal with it.', + created_at: 1574718902724, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-1', + }, + ], + updated_at: '2019-11-25T21:55:14.633Z', + version: 'WzMsMV0=', + }, + { + type: 'case-workflow-comment', + id: 'mock-comment-3', + attributes: { + comment: 'Wow, good luck catching that bad meanie!', + created_at: 1574721150542, + created_by: { + full_name: null, + username: 'elastic', + }, + }, + references: [ + { + type: 'case-workflow', + name: 'associated-case-workflow', + id: 'mock-id-3', + }, + ], + updated_at: '2019-11-25T22:32:30.608Z', + version: 'WzYsMV0=', + }, +]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts new file mode 100644 index 00000000000000..b1881e394e796a --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandlerContext } from 'src/core/server'; + +export const createRouteContext = (client: any) => { + return ({ + core: { + savedObjects: { + client, + }, + }, + } as unknown) as RequestHandlerContext; +}; diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts new file mode 100644 index 00000000000000..9ea42ba42406b1 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/delete_case.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initDeleteCaseApi } from '../delete_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('DELETE case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initDeleteCaseApi, 'delete'); + }); + it(`deletes the case. responds with 204`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'mock-id-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(204); + }); + it(`returns an error when thrown from deleteCase service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'not-real', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + }); + it(`returns an error when thrown from getAllCaseComments service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'bad-guy', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); + it(`returns an error when thrown from deleteComment service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'delete', + params: { + id: 'valid-id', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts new file mode 100644 index 00000000000000..e50b3cbaa9c9a1 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/delete_comment.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initDeleteCommentApi } from '../delete_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('DELETE comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initDeleteCommentApi, 'delete'); + }); + it(`deletes the comment. responds with 204`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{comment_id}', + method: 'delete', + params: { + comment_id: 'mock-id-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(204); + }); + it(`returns an error when thrown from deleteComment service`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{comment_id}', + method: 'delete', + params: { + comment_id: 'bad-guy', + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts new file mode 100644 index 00000000000000..2f8a229c08f29c --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_all_cases.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initGetAllCasesApi } from '../get_all_cases'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET all cases', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetAllCasesApi, 'get'); + }); + it(`returns the case without case comments when includeComments is false`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'get', + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.saved_objects).toHaveLength(3); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts new file mode 100644 index 00000000000000..3c5f8e52d19466 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_case.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, + mockCasesErrorTriggerData, +} from '../__fixtures__'; +import { initGetCaseApi } from '../get_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetCaseApi, 'get'); + }); + it(`returns the case without case comments when includeComments is false`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'mock-id-1', + }, + method: 'get', + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload).toEqual(mockCases.find(s => s.id === 'mock-id-1')); + expect(response.payload.comments).toBeUndefined(); + }); + it(`returns an error when thrown from getCase`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'abcdefg', + }, + method: 'get', + query: { + includeComments: false, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); + it(`returns the case with case comments when includeComments is true`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'mock-id-1', + }, + method: 'get', + query: { + includeComments: true, + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(200); + expect(response.payload.comments.saved_objects).toHaveLength(3); + }); + it(`returns an error when thrown from getAllCaseComments`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + params: { + id: 'bad-guy', + }, + method: 'get', + query: { + includeComments: true, + }, + }); + + const theContext = createRouteContext( + createMockSavedObjectsRepository(mockCasesErrorTriggerData) + ); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(400); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts new file mode 100644 index 00000000000000..9b6a1e435838bd --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/get_comment.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCaseComments, +} from '../__fixtures__'; +import { initGetCommentApi } from '../get_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('GET comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initGetCommentApi, 'get'); + }); + it(`returns the comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{id}', + method: 'get', + params: { + id: 'mock-comment-1', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload).toEqual(mockCaseComments.find(s => s.id === 'mock-comment-1')); + }); + it(`returns an error when getComment throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comments/{id}', + method: 'get', + params: { + id: 'not-real', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts new file mode 100644 index 00000000000000..bb688dde4c58f8 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/post_case.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initPostCaseApi } from '../post_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('POST cases', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initPostCaseApi, 'post'); + }); + it(`Posts a new case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-it'); + expect(response.payload.attributes.created_by.username).toEqual('awesome'); + }); + it(`Returns an error if postNewCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'Throw an error', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['error'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if user authentication throws`, async () => { + routeHandler = await createRoute(initPostCaseApi, 'post', true); + + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases', + method: 'post', + body: { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + state: 'open', + tags: ['defacement'], + case_type: 'security', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(500); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts new file mode 100644 index 00000000000000..0c059b7f15ea48 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/post_comment.test.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initPostCommentApi } from '../post_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('POST comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initPostCommentApi, 'post'); + }); + it(`Posts a new comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-comment'); + expect(response.payload.references[0].id).toEqual('mock-id-1'); + }); + it(`Returns an error if the case does not exist`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'this-is-not-real', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if postNewCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Throw an error', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(400); + expect(response.payload.isBoom).toEqual(true); + }); + it(`Returns an error if user authentication throws`, async () => { + routeHandler = await createRoute(initPostCommentApi, 'post', true); + + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}/comment', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + comment: 'Wow, good luck catching that bad meanie!', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(500); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts new file mode 100644 index 00000000000000..7ed478d2e7c019 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_case.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCases, +} from '../__fixtures__'; +import { initUpdateCaseApi } from '../update_case'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('UPDATE case', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initUpdateCaseApi, 'post'); + }); + it(`Updates a case`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'post', + params: { + id: 'mock-id-1', + }, + body: { + state: 'closed', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-id-1'); + expect(response.payload.attributes.state).toEqual('closed'); + }); + it(`Returns an error if updateCase throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/{id}', + method: 'post', + params: { + id: 'mock-id-does-not-exist', + }, + body: { + state: 'closed', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCases)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts new file mode 100644 index 00000000000000..8aa84b45b7dbb2 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__tests__/update_comment.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + createMockSavedObjectsRepository, + createRoute, + createRouteContext, + mockCaseComments, +} from '../__fixtures__'; +import { initUpdateCommentApi } from '../update_comment'; +import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; +import { httpServerMock } from 'src/core/server/mocks'; + +describe('UPDATE comment', () => { + let routeHandler: RequestHandler; + beforeAll(async () => { + routeHandler = await createRoute(initUpdateCommentApi, 'post'); + }); + it(`Updates a comment`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comment/{id}', + method: 'post', + params: { + id: 'mock-comment-1', + }, + body: { + comment: 'Update my comment', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.id).toEqual('mock-comment-1'); + expect(response.payload.attributes.comment).toEqual('Update my comment'); + }); + it(`Returns an error if updateComment throws`, async () => { + const request = httpServerMock.createKibanaRequest({ + path: '/api/cases/comment/{id}', + method: 'post', + params: { + id: 'mock-comment-does-not-exist', + }, + body: { + comment: 'Update my comment', + }, + }); + + const theContext = createRouteContext(createMockSavedObjectsRepository(mockCaseComments)); + + const response = await routeHandler(theContext, request, kibanaResponseFactory); + expect(response.status).toEqual(404); + expect(response.payload.isBoom).toEqual(true); + }); +}); diff --git a/x-pack/plugins/case/server/routes/api/delete_case.ts b/x-pack/plugins/case/server/routes/api/delete_case.ts new file mode 100644 index 00000000000000..a5ae72b8b46ff0 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/delete_case.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initDeleteCaseApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + let allCaseComments; + try { + await caseService.deleteCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + allCaseComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + if (allCaseComments.saved_objects.length > 0) { + await Promise.all( + allCaseComments.saved_objects.map(({ id }) => + caseService.deleteComment({ + client: context.core.savedObjects.client, + commentId: id, + }) + ) + ); + } + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/delete_comment.ts b/x-pack/plugins/case/server/routes/api/delete_comment.ts new file mode 100644 index 00000000000000..4a540dd9fd69ff --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/delete_comment.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initDeleteCommentApi({ caseService, router }: RouteDeps) { + router.delete( + { + path: '/api/cases/comments/{comment_id}', + validate: { + params: schema.object({ + comment_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + const client = context.core.savedObjects.client; + try { + await caseService.deleteComment({ + client, + commentId: request.params.comment_id, + }); + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts new file mode 100644 index 00000000000000..cc4956ead1bd78 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_all_case_comments.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetAllCaseCommentsApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{id}/comments', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const theComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + return response.ok({ body: theComments }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_all_cases.ts b/x-pack/plugins/case/server/routes/api/get_all_cases.ts new file mode 100644 index 00000000000000..749a183dfe9807 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_all_cases.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetAllCasesApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases', + validate: false, + }, + async (context, request, response) => { + try { + const cases = await caseService.getAllCases({ + client: context.core.savedObjects.client, + }); + return response.ok({ body: cases }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_case.ts b/x-pack/plugins/case/server/routes/api/get_case.ts new file mode 100644 index 00000000000000..6aad22a1ebf1b6 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_case.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetCaseApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + query: schema.object({ + includeComments: schema.string({ defaultValue: 'true' }), + }), + }, + }, + async (context, request, response) => { + let theCase; + const includeComments = JSON.parse(request.query.includeComments); + try { + theCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + if (!includeComments) { + return response.ok({ body: theCase }); + } + try { + const theComments = await caseService.getAllCaseComments({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + return response.ok({ body: { ...theCase, comments: theComments } }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/get_comment.ts b/x-pack/plugins/case/server/routes/api/get_comment.ts new file mode 100644 index 00000000000000..6fd507d89738da --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/get_comment.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '.'; +import { wrapError } from './utils'; + +export function initGetCommentApi({ caseService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/comments/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const theComment = await caseService.getComment({ + client: context.core.savedObjects.client, + commentId: request.params.id, + }); + return response.ok({ body: theComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts new file mode 100644 index 00000000000000..11ef91d539e87b --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'src/core/server'; +import { initDeleteCommentApi } from './delete_comment'; +import { initDeleteCaseApi } from './delete_case'; +import { initGetAllCaseCommentsApi } from './get_all_case_comments'; +import { initGetAllCasesApi } from './get_all_cases'; +import { initGetCaseApi } from './get_case'; +import { initGetCommentApi } from './get_comment'; +import { initPostCaseApi } from './post_case'; +import { initPostCommentApi } from './post_comment'; +import { initUpdateCaseApi } from './update_case'; +import { initUpdateCommentApi } from './update_comment'; +import { CaseServiceSetup } from '../../services'; + +export interface RouteDeps { + caseService: CaseServiceSetup; + router: IRouter; +} + +export function initCaseApi(deps: RouteDeps) { + initGetAllCaseCommentsApi(deps); + initGetAllCasesApi(deps); + initGetCaseApi(deps); + initGetCommentApi(deps); + initDeleteCaseApi(deps); + initDeleteCommentApi(deps); + initPostCaseApi(deps); + initPostCommentApi(deps); + initUpdateCaseApi(deps); + initUpdateCommentApi(deps); +} diff --git a/x-pack/plugins/case/server/routes/api/post_case.ts b/x-pack/plugins/case/server/routes/api/post_case.ts new file mode 100644 index 00000000000000..e5aa0a3548b48f --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/post_case.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { formatNewCase, wrapError } from './utils'; +import { NewCaseSchema } from './schema'; +import { RouteDeps } from '.'; + +export function initPostCaseApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases', + validate: { + body: NewCaseSchema, + }, + }, + async (context, request, response) => { + let createdBy; + try { + createdBy = await caseService.getUser({ request, response }); + } catch (error) { + return response.customError(wrapError(error)); + } + + try { + const newCase = await caseService.postNewCase({ + client: context.core.savedObjects.client, + attributes: formatNewCase(request.body, { + ...createdBy, + }), + }); + return response.ok({ body: newCase }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/post_comment.ts b/x-pack/plugins/case/server/routes/api/post_comment.ts new file mode 100644 index 00000000000000..3f4592f5bb11f3 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/post_comment.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { formatNewComment, wrapError } from './utils'; +import { NewCommentSchema } from './schema'; +import { RouteDeps } from '.'; +import { CASE_SAVED_OBJECT } from '../../constants'; + +export function initPostCommentApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/{id}/comment', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: NewCommentSchema, + }, + }, + async (context, request, response) => { + let createdBy; + let newComment; + try { + await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + createdBy = await caseService.getUser({ request, response }); + } catch (error) { + return response.customError(wrapError(error)); + } + try { + newComment = await caseService.postNewComment({ + client: context.core.savedObjects.client, + attributes: formatNewComment({ + newComment: request.body, + ...createdBy, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: request.params.id, + }, + ], + }); + + return response.ok({ body: newComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/schema.ts b/x-pack/plugins/case/server/routes/api/schema.ts new file mode 100644 index 00000000000000..4a4a0c3a11e362 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/schema.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +export const UserSchema = schema.object({ + username: schema.string(), + full_name: schema.maybe(schema.string()), +}); + +export const NewCommentSchema = schema.object({ + comment: schema.string(), +}); + +export const CommentSchema = schema.object({ + comment: schema.string(), + created_at: schema.number(), + created_by: UserSchema, +}); + +export const UpdatedCommentSchema = schema.object({ + comment: schema.string(), +}); + +export const NewCaseSchema = schema.object({ + assignees: schema.arrayOf(UserSchema, { defaultValue: [] }), + description: schema.string(), + title: schema.string(), + state: schema.oneOf([schema.literal('open'), schema.literal('closed')], { defaultValue: 'open' }), + tags: schema.arrayOf(schema.string(), { defaultValue: [] }), + case_type: schema.string(), +}); + +export const UpdatedCaseSchema = schema.object({ + assignees: schema.maybe(schema.arrayOf(UserSchema)), + description: schema.maybe(schema.string()), + title: schema.maybe(schema.string()), + state: schema.maybe(schema.oneOf([schema.literal('open'), schema.literal('closed')])), + tags: schema.maybe(schema.arrayOf(schema.string())), + case_type: schema.maybe(schema.string()), +}); diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts new file mode 100644 index 00000000000000..d943e4e5fd7dd7 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { + CommentSchema, + NewCaseSchema, + NewCommentSchema, + UpdatedCaseSchema, + UpdatedCommentSchema, + UserSchema, +} from './schema'; + +export type NewCaseType = TypeOf; +export type NewCommentFormatted = TypeOf; +export type NewCommentType = TypeOf; +export type UpdatedCaseTyped = TypeOf; +export type UpdatedCommentType = TypeOf; +export type UserType = TypeOf; + +export interface NewCaseFormatted extends NewCaseType { + created_at: number; + created_by: UserType; +} + +export interface UpdatedCaseType { + assignees?: UpdatedCaseTyped['assignees']; + description?: UpdatedCaseTyped['description']; + title?: UpdatedCaseTyped['title']; + state?: UpdatedCaseTyped['state']; + tags?: UpdatedCaseTyped['tags']; + case_type?: UpdatedCaseTyped['case_type']; +} diff --git a/x-pack/plugins/case/server/routes/api/update_case.ts b/x-pack/plugins/case/server/routes/api/update_case.ts new file mode 100644 index 00000000000000..52c8cab0022dd1 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/update_case.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { wrapError } from './utils'; +import { RouteDeps } from '.'; +import { UpdatedCaseSchema } from './schema'; + +export function initUpdateCaseApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: UpdatedCaseSchema, + }, + }, + async (context, request, response) => { + try { + const updatedCase = await caseService.updateCase({ + client: context.core.savedObjects.client, + caseId: request.params.id, + updatedAttributes: request.body, + }); + return response.ok({ body: updatedCase }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/update_comment.ts b/x-pack/plugins/case/server/routes/api/update_comment.ts new file mode 100644 index 00000000000000..e1ee6029e8e4fc --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/update_comment.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { wrapError } from './utils'; +import { NewCommentSchema } from './schema'; +import { RouteDeps } from '.'; + +export function initUpdateCommentApi({ caseService, router }: RouteDeps) { + router.post( + { + path: '/api/cases/comment/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: NewCommentSchema, + }, + }, + async (context, request, response) => { + try { + const updatedComment = await caseService.updateComment({ + client: context.core.savedObjects.client, + commentId: request.params.id, + updatedAttributes: request.body, + }); + return response.ok({ body: updatedComment }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts new file mode 100644 index 00000000000000..c6e33dbb8433bd --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { boomify, isBoom } from 'boom'; +import { CustomHttpResponseOptions, ResponseError } from 'kibana/server'; +import { + NewCaseType, + NewCaseFormatted, + NewCommentType, + NewCommentFormatted, + UserType, +} from './types'; + +export const formatNewCase = ( + newCase: NewCaseType, + { full_name, username }: { full_name?: string; username: string } +): NewCaseFormatted => ({ + created_at: new Date().valueOf(), + created_by: { full_name, username }, + ...newCase, +}); + +interface NewCommentArgs { + newComment: NewCommentType; + full_name?: UserType['full_name']; + username: UserType['username']; +} +export const formatNewComment = ({ + newComment, + full_name, + username, +}: NewCommentArgs): NewCommentFormatted => ({ + ...newComment, + created_at: new Date().valueOf(), + created_by: { full_name, username }, +}); + +export function wrapError(error: any): CustomHttpResponseOptions { + const boom = isBoom(error) ? error : boomify(error); + return { + body: boom, + headers: boom.output.headers, + statusCode: boom.output.statusCode, + }; +} diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts new file mode 100644 index 00000000000000..684d905a5c71fe --- /dev/null +++ b/x-pack/plugins/case/server/services/index.ts @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + KibanaRequest, + KibanaResponseFactory, + Logger, + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsUpdateResponse, + SavedObjectReference, +} from 'kibana/server'; +import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../constants'; +import { + NewCaseFormatted, + NewCommentFormatted, + UpdatedCaseType, + UpdatedCommentType, +} from '../routes/api/types'; +import { + AuthenticatedUser, + PluginSetupContract as SecurityPluginSetup, +} from '../../../security/server'; + +interface ClientArgs { + client: SavedObjectsClientContract; +} + +interface GetCaseArgs extends ClientArgs { + caseId: string; +} +interface GetCommentArgs extends ClientArgs { + commentId: string; +} +interface PostCaseArgs extends ClientArgs { + attributes: NewCaseFormatted; +} + +interface PostCommentArgs extends ClientArgs { + attributes: NewCommentFormatted; + references: SavedObjectReference[]; +} +interface UpdateCaseArgs extends ClientArgs { + caseId: string; + updatedAttributes: UpdatedCaseType; +} +interface UpdateCommentArgs extends ClientArgs { + commentId: string; + updatedAttributes: UpdatedCommentType; +} + +interface GetUserArgs { + request: KibanaRequest; + response: KibanaResponseFactory; +} + +interface CaseServiceDeps { + authentication: SecurityPluginSetup['authc']; +} +export interface CaseServiceSetup { + deleteCase(args: GetCaseArgs): Promise<{}>; + deleteComment(args: GetCommentArgs): Promise<{}>; + getAllCases(args: ClientArgs): Promise; + getAllCaseComments(args: GetCaseArgs): Promise; + getCase(args: GetCaseArgs): Promise; + getComment(args: GetCommentArgs): Promise; + getUser(args: GetUserArgs): Promise; + postNewCase(args: PostCaseArgs): Promise; + postNewComment(args: PostCommentArgs): Promise; + updateCase(args: UpdateCaseArgs): Promise; + updateComment(args: UpdateCommentArgs): Promise; +} + +export class CaseService { + constructor(private readonly log: Logger) {} + public setup = async ({ authentication }: CaseServiceDeps): Promise => ({ + deleteCase: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.delete(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + }, + deleteComment: async ({ client, commentId }: GetCommentArgs) => { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + }, + getCase: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.get(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + }, + getComment: async ({ client, commentId }: GetCommentArgs) => { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + }, + getAllCases: async ({ client }: ClientArgs) => { + try { + this.log.debug(`Attempting to GET all cases`); + return await client.find({ type: CASE_SAVED_OBJECT }); + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + }, + getAllCaseComments: async ({ client, caseId }: GetCaseArgs) => { + try { + this.log.debug(`Attempting to GET all comments for case ${caseId}`); + return await client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for case ${caseId}: ${error}`); + throw error; + } + }, + getUser: async ({ request, response }: GetUserArgs) => { + let user; + try { + this.log.debug(`Attempting to authenticate a user`); + user = await authentication!.getCurrentUser(request); + } catch (error) { + this.log.debug(`Error on GET user: ${error}`); + throw error; + } + if (!user) { + this.log.debug(`Error on GET user: Bad User`); + throw new Error('Bad User - the user is not authenticated'); + } + return user; + }, + postNewCase: async ({ client, attributes }: PostCaseArgs) => { + try { + this.log.debug(`Attempting to POST a new case`); + return await client.create(CASE_SAVED_OBJECT, { ...attributes }); + } catch (error) { + this.log.debug(`Error on POST a new case: ${error}`); + throw error; + } + }, + postNewComment: async ({ client, attributes, references }: PostCommentArgs) => { + try { + this.log.debug(`Attempting to POST a new comment`); + return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); + } catch (error) { + this.log.debug(`Error on POST a new comment: ${error}`); + throw error; + } + }, + updateCase: async ({ client, caseId, updatedAttributes }: UpdateCaseArgs) => { + try { + this.log.debug(`Attempting to UPDATE case ${caseId}`); + return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }); + } catch (error) { + this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); + throw error; + } + }, + updateComment: async ({ client, commentId, updatedAttributes }: UpdateCommentArgs) => { + try { + this.log.debug(`Attempting to UPDATE comment ${commentId}`); + return await client.update(CASE_COMMENT_SAVED_OBJECT, commentId, { + ...updatedAttributes, + }); + } catch (error) { + this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); + throw error; + } + }, + }); +} diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 33f554be5caa3b..17e49b8cf40d30 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -17,6 +17,7 @@ import { Plugin, PluginSetupContract, PluginSetupDependencies } from './plugin'; // These exports are part of public Security plugin contract, any change in signature of exported // functions or removal of exports should be considered as a breaking change. export { + Authentication, AuthenticationResult, DeauthenticationResult, CreateAPIKeyResult, @@ -24,6 +25,7 @@ export { InvalidateAPIKeyResult, } from './authentication'; export { PluginSetupContract }; +export { AuthenticatedUser } from '../common/model'; export const config: PluginConfigDescriptor> = { schema: ConfigSchema,