diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 9b99bf0e54cc2..a08e1fbca66ea 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -29,12 +29,17 @@ const CaseStatusRt = rt.union([ export const caseStatuses = Object.values(CaseStatuses); +const SettingsRt = rt.type({ + syncAlerts: rt.boolean, +}); + const CaseBasicRt = rt.type({ - connector: CaseConnectorRt, description: rt.string, status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, + connector: CaseConnectorRt, + settings: SettingsRt, }); const CaseExternalServiceBasicRt = rt.type({ @@ -74,6 +79,7 @@ export const CasePostRequestRt = rt.type({ tags: rt.array(rt.string), title: rt.string, connector: CaseConnectorRt, + settings: SettingsRt, }); export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index 1a3ccfc04eed9..e7aa67db9287e 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -20,6 +20,7 @@ const UserActionFieldRt = rt.array( rt.literal('tags'), rt.literal('title'), rt.literal('status'), + rt.literal('settings'), ]) ); const UserActionRt = rt.union([ diff --git a/x-pack/plugins/case/kibana.json b/x-pack/plugins/case/kibana.json index 55416ee28c7df..2048ae41fa8ab 100644 --- a/x-pack/plugins/case/kibana.json +++ b/x-pack/plugins/case/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack", "case"], "id": "case", "kibanaVersion": "kibana", - "requiredPlugins": ["actions"], + "requiredPlugins": ["actions", "securitySolution"], "optionalPlugins": [ "spaces", "security" diff --git a/x-pack/plugins/case/server/client/alerts/update_status.ts b/x-pack/plugins/case/server/client/alerts/update_status.ts new file mode 100644 index 0000000000000..d90424eb5fb15 --- /dev/null +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -0,0 +1,25 @@ +/* + * 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 Boom from '@hapi/boom'; +import { CaseClientUpdateAlertsStatus, CaseClientFactoryArguments } from '../types'; + +export const updateAlertsStatus = ({ + alertsService, + request, + context, +}: CaseClientFactoryArguments) => async ({ + ids, + status, +}: CaseClientUpdateAlertsStatus): Promise => { + const securitySolutionClient = context?.securitySolution?.getAppClient(); + if (securitySolutionClient == null) { + throw Boom.notFound('securitySolutionClient client have not been found'); + } + + const index = securitySolutionClient.getSignalsIndex(); + await alertsService.updateAlertsStatus({ ids, status, index, request }); +}; diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index e09ce226b3125..90116e3728883 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -34,6 +34,9 @@ describe('create', () => { type: ConnectorTypes.jira, fields: { issueType: 'Task', priority: 'High', parent: null }, }, + settings: { + syncAlerts: true, + }, } as CasePostRequest; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -65,6 +68,9 @@ describe('create', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); expect( @@ -79,9 +85,9 @@ describe('create', () => { full_name: 'Awesome D00d', username: 'awesome', }, - action_field: ['description', 'status', 'tags', 'title', 'connector'], + action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], new_value: - '{"description":"This is a brand new case of a bad meanie defacing data","title":"Super Bad Security Issue","tags":["defacement"],"connector":{"id":"123","name":"Jira","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}}}', + '{"description":"This is a brand new case of a bad meanie defacing data","title":"Super Bad Security Issue","tags":["defacement"],"connector":{"id":"123","name":"Jira","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}},"settings":{"syncAlerts":true}}', old_value: null, }, references: [ @@ -106,6 +112,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -131,6 +140,9 @@ describe('create', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); }); @@ -145,6 +157,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -174,6 +189,9 @@ describe('create', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); }); }); @@ -323,6 +341,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -347,6 +368,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index 59222be062c75..1dca025036c1e 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -64,7 +64,7 @@ export const create = ({ actionAt: createdDate, actionBy: { username, full_name, email }, caseId: newCase.id, - fields: ['description', 'status', 'tags', 'title', 'connector'], + fields: ['description', 'status', 'tags', 'title', 'connector', 'settings'], newValue: JSON.stringify(query), }), ], diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index ae701f16b2bcb..1f9e8cc788404 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -38,7 +38,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -63,6 +66,9 @@ describe('update', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); @@ -115,7 +121,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -140,6 +149,9 @@ describe('update', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -160,7 +172,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -185,6 +200,9 @@ describe('update', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -210,7 +228,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -243,6 +264,9 @@ describe('update', () => { username: 'awesome', }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -328,7 +352,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.update({ cases: patchCases }).catch((e) => { + caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(406); @@ -358,7 +382,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.update({ cases: patchCases }).catch((e) => { + caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(404); @@ -385,7 +409,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.update({ cases: patchCases }).catch((e) => { + caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(409); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 406e43a74cccf..e2b6cb8337251 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -9,6 +9,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; +import { SavedObjectsFindResponse } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; import { @@ -34,7 +35,10 @@ export const update = ({ caseService, userActionService, request, -}: CaseClientFactoryArguments) => async ({ cases }: CaseClientUpdate): Promise => { +}: CaseClientFactoryArguments) => async ({ + caseClient, + cases, +}: CaseClientUpdate): Promise => { const query = pipe( excess(CasesPatchRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) @@ -126,6 +130,65 @@ export const update = ({ }), }); + // If a status update occurred and the case is synced then we need to update all alerts' status + // attached to the case to the new status. + const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.status != null && + currentCase.attributes.status !== caseToUpdate.status && + currentCase.attributes.settings.syncAlerts + ); + }); + + // If syncAlerts setting turned on we need to update all alerts' status + // attached to the case to the current status. + const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.settings?.syncAlerts != null && + currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && + caseToUpdate.settings.syncAlerts + ); + }); + + for (const theCase of [ + ...casesWithSyncSettingChangedToOn, + ...casesWithStatusChangedAndSynced, + ]) { + const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); + const totalComments = await caseService.getAllCaseComments({ + client: savedObjectsClient, + caseId: theCase.id, + options: { + fields: [], + filter: 'cases-comments.attributes.type: alert', + page: 1, + perPage: 1, + }, + }); + + const caseComments = (await caseService.getAllCaseComments({ + client: savedObjectsClient, + caseId: theCase.id, + options: { + fields: [], + filter: 'cases-comments.attributes.type: alert', + page: 1, + perPage: totalComments.total, + }, + // The filter guarantees that the comments will be of type alert + })) as SavedObjectsFindResponse<{ alertId: string }>; + + caseClient.updateAlertsStatus({ + ids: caseComments.saved_objects.map(({ attributes: { alertId } }) => alertId), + // Either there is a status update or the syncAlerts got turned on. + status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, + }); + } + const returnUpdatedCase = myCases.saved_objects .filter((myCase) => updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index d00df5a3246bd..40b87f6ad17f0 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -31,6 +31,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -66,6 +67,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -103,6 +105,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -126,6 +129,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -173,6 +177,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient, true); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -267,6 +272,7 @@ describe('addComment', () => { ['alertId', 'index'].forEach((attribute) => { caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -328,6 +334,7 @@ describe('addComment', () => { ['comment'].forEach((attribute) => { caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -354,6 +361,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'not-exists', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -377,6 +385,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Throw an error', diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 169157c95d4c1..bb61094cfa3bd 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -11,7 +11,14 @@ import { identity } from 'fp-ts/lib/function'; import { decodeComment, flattenCaseSavedObject, transformNewComment } from '../../routes/api/utils'; -import { throwErrors, CaseResponseRt, CommentRequestRt, CaseResponse } from '../../../common/api'; +import { + throwErrors, + CaseResponseRt, + CommentRequestRt, + CaseResponse, + CommentType, + CaseStatuses, +} from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; @@ -23,11 +30,11 @@ export const addComment = ({ userActionService, request, }: CaseClientFactoryArguments) => async ({ + caseClient, caseId, comment, }: CaseClientAddComment): Promise => { const query = pipe( - // TODO: Excess CommentRequestRt when the excess() function supports union types CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); @@ -39,6 +46,11 @@ export const addComment = ({ caseId, }); + // An alert cannot be attach to a closed case. + if (query.type === CommentType.alert && myCase.attributes.status === CaseStatuses.closed) { + throw Boom.badRequest('Alert cannot be attached to a closed case'); + } + // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); const createdDate = new Date().toISOString(); @@ -72,6 +84,14 @@ export const addComment = ({ }), ]); + // If the case is synced with alerts the newly attached alert must match the status of the case. + if (newComment.attributes.type === CommentType.alert && myCase.attributes.settings.syncAlerts) { + caseClient.updateAlertsStatus({ + ids: [newComment.attributes.alertId], + status: myCase.attributes.status, + }); + } + const totalCommentsFindByCases = await caseService.getAllCaseComments({ client: savedObjectsClient, caseId, diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 1ecdc8ea96dea..ef4491204d9f5 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -4,32 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { createCaseClient } from '.'; import { createCaseServiceMock, createConfigureServiceMock, createUserActionServiceMock, + createAlertServiceMock, } from '../services/mocks'; import { create } from './cases/create'; import { update } from './cases/update'; import { addComment } from './comments/add'; +import { updateAlertsStatus } from './alerts/update_status'; jest.mock('./cases/create'); jest.mock('./cases/update'); jest.mock('./comments/add'); +jest.mock('./alerts/update_status'); const caseService = createCaseServiceMock(); const caseConfigureService = createConfigureServiceMock(); const userActionService = createUserActionServiceMock(); +const alertsService = createAlertServiceMock(); const savedObjectsClient = savedObjectsClientMock.create(); const request = {} as KibanaRequest; +const context = {} as RequestHandlerContext; const createMock = create as jest.Mock; const updateMock = update as jest.Mock; const addCommentMock = addComment as jest.Mock; +const updateAlertsStatusMock = updateAlertsStatus as jest.Mock; describe('createCaseClient()', () => { test('it creates the client correctly', async () => { @@ -39,6 +45,8 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, }); expect(createMock).toHaveBeenCalledWith({ @@ -47,6 +55,8 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, }); expect(updateMock).toHaveBeenCalledWith({ @@ -55,6 +65,8 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, }); expect(addCommentMock).toHaveBeenCalledWith({ @@ -63,6 +75,18 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, + }); + + expect(updateAlertsStatusMock).toHaveBeenCalledWith({ + savedObjectsClient, + request, + caseConfigureService, + caseService, + userActionService, + alertsService, + context, }); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 75e9e3c4cfebc..bf43921b46466 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -8,6 +8,7 @@ import { CaseClientFactoryArguments, CaseClient } from './types'; import { create } from './cases/create'; import { update } from './cases/update'; import { addComment } from './comments/add'; +import { updateAlertsStatus } from './alerts/update_status'; export { CaseClient } from './types'; @@ -17,6 +18,8 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, }: CaseClientFactoryArguments): CaseClient => { return { create: create({ @@ -25,6 +28,8 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, }), update: update({ savedObjectsClient, @@ -32,6 +37,8 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, }), addComment: addComment({ savedObjectsClient, @@ -39,6 +46,17 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, + }), + updateAlertsStatus: updateAlertsStatus({ + savedObjectsClient, + request, + caseConfigureService, + caseService, + userActionService, + alertsService, + context, }), }; }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 243dd884f9ef6..dd4e8b52b4dc6 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -4,18 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'kibana/server'; -import { loggingSystemMock } from '../../../../../src/core/server/mocks'; -import { CaseService, CaseConfigureService, CaseUserActionServiceSetup } from '../services'; +import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; +import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { actionsClientMock } from '../../../actions/server/mocks'; +import { + CaseService, + CaseConfigureService, + CaseUserActionServiceSetup, + AlertService, +} from '../services'; import { CaseClient } from './types'; import { authenticationMock } from '../routes/api/__fixtures__'; import { createCaseClient } from '.'; +import { getActions } from '../routes/api/__mocks__/request_responses'; export type CaseClientMock = jest.Mocked; export const createCaseClientMock = (): CaseClientMock => ({ create: jest.fn(), update: jest.fn(), addComment: jest.fn(), + updateAlertsStatus: jest.fn(), }); export const createCaseClientWithMockSavedObjectsClient = async ( @@ -25,7 +33,10 @@ export const createCaseClientWithMockSavedObjectsClient = async ( client: CaseClient; services: { userActionService: jest.Mocked }; }> => { + const actionsMock = actionsClientMock.create(); + actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); const log = loggingSystemMock.create().get('case'); + const esClientMock = elasticsearchServiceMock.createClusterClient(); const request = {} as KibanaRequest; const caseServicePlugin = new CaseService(log); @@ -39,15 +50,38 @@ export const createCaseClientWithMockSavedObjectsClient = async ( postUserActions: jest.fn(), getUserActions: jest.fn(), }; + const alertsService = new AlertService(); + alertsService.initialize(esClientMock); + + const context = ({ + core: { + savedObjects: { + client: savedObjectsClient, + }, + }, + actions: { getActionsClient: () => actionsMock }, + case: { + getCaseClient: () => caseClient, + }, + securitySolution: { + getAppClient: () => ({ + getSignalsIndex: () => '.siem-signals', + }), + }, + } as unknown) as RequestHandlerContext; + + const caseClient = createCaseClient({ + savedObjectsClient, + request, + caseService, + caseConfigureService, + userActionService, + alertsService, + context, + }); return { - client: createCaseClient({ - savedObjectsClient, - request, - caseService, - caseConfigureService, - userActionService, - }), + client: caseClient, services: { userActionService }, }; }; diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 8db7d8a5747d7..a9e8494c43dbc 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, SavedObjectsClientContract } from '../../../../../src/core/server'; +import { KibanaRequest, SavedObjectsClientContract, RequestHandlerContext } from 'kibana/server'; import { CasePostRequest, CasesPatchRequest, CommentRequest, CaseResponse, CasesResponse, + CaseStatuses, } from '../../common/api'; import { CaseConfigureServiceSetup, CaseServiceSetup, CaseUserActionServiceSetup, + AlertServiceContract, } from '../services'; export interface CaseClientCreate { @@ -23,24 +25,36 @@ export interface CaseClientCreate { } export interface CaseClientUpdate { + caseClient: CaseClient; cases: CasesPatchRequest; } export interface CaseClientAddComment { + caseClient: CaseClient; caseId: string; comment: CommentRequest; } +export interface CaseClientUpdateAlertsStatus { + ids: string[]; + status: CaseStatuses; +} + +type PartialExceptFor = Partial & Pick; + export interface CaseClientFactoryArguments { savedObjectsClient: SavedObjectsClientContract; request: KibanaRequest; caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; + context?: PartialExceptFor; } export interface CaseClient { create: (args: CaseClientCreate) => Promise; update: (args: CaseClientUpdate) => Promise; addComment: (args: CaseClientAddComment) => Promise; + updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise; } diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index adf94661216cb..9f5b186c0c687 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -14,6 +14,7 @@ import { createCaseServiceMock, createConfigureServiceMock, createUserActionServiceMock, + createAlertServiceMock, } from '../../services/mocks'; import { CaseActionType, CaseActionTypeExecutorOptions, CaseExecutorParams } from './types'; import { getActionType } from '.'; @@ -35,11 +36,13 @@ describe('case connector', () => { const caseService = createCaseServiceMock(); const caseConfigureService = createConfigureServiceMock(); const userActionService = createUserActionServiceMock(); + const alertsService = createAlertServiceMock(); caseActionType = getActionType({ logger, caseService, caseConfigureService, userActionService, + alertsService, }); }); @@ -62,6 +65,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -98,6 +104,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -118,6 +127,9 @@ describe('case connector', () => { severityCode: '3', }, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -139,6 +151,9 @@ describe('case connector', () => { urgency: 'Medium', }, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -156,6 +171,9 @@ describe('case connector', () => { type: '.none', fields: null, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -180,6 +198,9 @@ describe('case connector', () => { type: '.servicenow', fields: {}, }, + settings: { + syncAlerts: true, + }, }, }; @@ -195,6 +216,9 @@ describe('case connector', () => { type: '.servicenow', fields: { impact: null, severity: null, urgency: null }, }, + settings: { + syncAlerts: true, + }, }, }); }); @@ -212,6 +236,9 @@ describe('case connector', () => { type: '.none', fields: null, }, + settings: { + syncAlerts: true, + }, }, }; @@ -234,6 +261,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -262,6 +292,9 @@ describe('case connector', () => { excess: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -289,6 +322,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -312,6 +348,9 @@ describe('case connector', () => { type: '.none', fields: {}, }, + settings: { + syncAlerts: true, + }, }, }; @@ -343,6 +382,7 @@ describe('case connector', () => { title: null, status: null, connector: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -375,6 +415,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -405,6 +446,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -436,6 +478,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -465,6 +508,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, connector: { id: 'servicenow', name: 'Servicenow', @@ -497,6 +541,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -630,7 +675,9 @@ describe('case connector', () => { expect(validateParams(caseActionType, params)).toEqual(params); }); - it('succeeds when type is an alert', () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('succeeds when type is an alert', () => { const params: Record = { subAction: 'addComment', subActionParams: { @@ -656,6 +703,26 @@ describe('case connector', () => { }).toThrow(); }); + // TODO: Remove it when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it('fails when type is an alert', () => { + const params: Record = { + subAction: 'addComment', + subActionParams: { + caseId: 'case-id', + comment: { + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow(); + }); + it('fails when missing attributes: type user', () => { const allParams = { type: CommentType.user, @@ -678,7 +745,9 @@ describe('case connector', () => { }); }); - it('fails when missing attributes: type alert', () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('fails when missing attributes: type alert', () => { const allParams = { type: CommentType.alert, comment: 'a comment', @@ -720,7 +789,9 @@ describe('case connector', () => { }); }); - it('fails when excess attributes are provided: type alert', () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('fails when excess attributes are provided: type alert', () => { ['comment'].forEach((attribute) => { const params: Record = { subAction: 'addComment', @@ -789,6 +860,9 @@ describe('case connector', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }; mockCaseClient.create.mockReturnValue(Promise.resolve(createReturn)); @@ -810,6 +884,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -879,6 +956,9 @@ describe('case connector', () => { username: 'awesome', }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]; @@ -895,6 +975,7 @@ describe('case connector', () => { tags: null, status: null, connector: null, + settings: null, }, }; @@ -910,6 +991,7 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: updateReturn }); expect(mockCaseClient.update).toHaveBeenCalledWith({ + caseClient: mockCaseClient, // Null values have been striped out. cases: { cases: [ @@ -960,6 +1042,9 @@ describe('case connector', () => { version: 'WzksMV0=', }, ], + settings: { + syncAlerts: true, + }, }; mockCaseClient.addComment.mockReturnValue(Promise.resolve(commentReturn)); @@ -988,6 +1073,7 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: commentReturn }); expect(mockCaseClient.addComment).toHaveBeenCalledWith({ + caseClient: mockCaseClient, caseId: 'case-id', comment: { comment: 'a comment', diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index dc647d288ec65..48124b8ae32eb 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -6,7 +6,7 @@ import { curry } from 'lodash'; -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest } from '../../../common/api'; import { createCaseClient } from '../../client'; @@ -30,6 +30,7 @@ export function getActionType({ caseService, caseConfigureService, userActionService, + alertsService, }: GetActionTypeParams): CaseActionType { return { id: CASE_ACTION_TYPE_ID, @@ -39,13 +40,25 @@ export function getActionType({ config: CaseConfigurationSchema, params: CaseExecutorParamsSchema, }, - executor: curry(executor)({ logger, caseService, caseConfigureService, userActionService }), + executor: curry(executor)({ + logger, + caseService, + caseConfigureService, + userActionService, + alertsService, + }), }; } // action executor async function executor( - { logger, caseService, caseConfigureService, userActionService }: GetActionTypeParams, + { + logger, + caseService, + caseConfigureService, + userActionService, + alertsService, + }: GetActionTypeParams, execOptions: CaseActionTypeExecutorOptions ): Promise> { const { actionId, params, services } = execOptions; @@ -59,6 +72,9 @@ async function executor( caseService, caseConfigureService, userActionService, + alertsService, + // TODO: When case connector is enabled we should figure out how to pass the context. + context: {} as RequestHandlerContext, }); if (!supportedSubActions.includes(subAction)) { @@ -80,12 +96,15 @@ async function executor( {} as CasePatchRequest ); - data = await caseClient.update({ cases: { cases: [updateParamsWithoutNullValues] } }); + data = await caseClient.update({ + caseClient, + cases: { cases: [updateParamsWithoutNullValues] }, + }); } if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - data = await caseClient.addComment({ caseId, comment }); + data = await caseClient.addComment({ caseClient, caseId, comment }); } return { status: 'ok', data: data ?? {}, actionId }; diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 039c0e2e7e67f..d17c9ce6eb1cc 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -14,13 +14,27 @@ const ContextTypeUserSchema = schema.object({ comment: schema.string(), }); -const ContextTypeAlertSchema = schema.object({ - type: schema.literal('alert'), - alertId: schema.string(), - index: schema.string(), -}); - -export const CommentSchema = schema.oneOf([ContextTypeUserSchema, ContextTypeAlertSchema]); +/** + * ContextTypeAlertSchema has been deleted. + * Comments of type alert need the siem signal index. + * Case connector is not being passed the context which contains the + * security solution app client which in turn provides the siem signal index. + * For that reason, we disable comments of type alert for the case connector until + * we figure out how to pass the security solution app client to the connector. + * See: x-pack/plugins/case/server/connectors/case/index.ts L76. + * + * The schema: + * + * const ContextTypeAlertSchema = schema.object({ + * type: schema.literal('alert'), + * alertId: schema.string(), + * index: schema.string(), + * }); + * + * Issue: https://github.com/elastic/kibana/issues/85750 + * */ + +export const CommentSchema = schema.oneOf([ContextTypeUserSchema]); const JiraFieldsSchema = schema.object({ issueType: schema.string(), @@ -80,6 +94,7 @@ const CaseBasicProps = { title: schema.string(), tags: schema.arrayOf(schema.string()), connector: schema.object(ConnectorProps, { validate: validateConnector }), + settings: schema.object({ syncAlerts: schema.boolean() }), }; const CaseUpdateRequestProps = { @@ -89,6 +104,7 @@ const CaseUpdateRequestProps = { title: schema.nullable(CaseBasicProps.title), tags: schema.nullable(CaseBasicProps.tags), connector: schema.nullable(CaseBasicProps.connector), + settings: schema.nullable(CaseBasicProps.settings), status: schema.nullable(schema.string()), }; diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index bee7b1e475457..f373445719164 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -16,6 +16,7 @@ import { CaseServiceSetup, CaseConfigureServiceSetup, CaseUserActionServiceSetup, + AlertServiceContract, } from '../services'; import { getActionType as getCaseConnector } from './case'; @@ -26,6 +27,7 @@ export interface GetActionTypeParams { caseService: CaseServiceSetup; caseConfigureService: CaseConfigureServiceSetup; userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; } export interface RegisterConnectorsArgs extends GetActionTypeParams { @@ -45,6 +47,7 @@ export const registerConnectors = ({ caseService, caseConfigureService, userActionService, + alertsService, }: RegisterConnectorsArgs) => { actionsRegisterType( getCaseConnector({ @@ -52,6 +55,7 @@ export const registerConnectors = ({ caseService, caseConfigureService, userActionService, + alertsService, }) ); }; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 64c4b422d1cf7..8d508ce0b76b1 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -11,6 +11,7 @@ import { Logger, PluginInitializerContext, RequestHandler, + RequestHandlerContext, } from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; @@ -33,6 +34,8 @@ import { CaseServiceSetup, CaseUserActionService, CaseUserActionServiceSetup, + AlertService, + AlertServiceContract, } from './services'; import { createCaseClient } from './client'; import { registerConnectors } from './connectors'; @@ -51,6 +54,7 @@ export class CasePlugin { private caseService?: CaseServiceSetup; private caseConfigureService?: CaseConfigureServiceSetup; private userActionService?: CaseUserActionServiceSetup; + private alertsService?: AlertService; constructor(private readonly initializerContext: PluginInitializerContext) { this.log = this.initializerContext.logger.get(); @@ -79,6 +83,7 @@ export class CasePlugin { }); this.caseConfigureService = await new CaseConfigureService(this.log).setup(); this.userActionService = await new CaseUserActionService(this.log).setup(); + this.alertsService = new AlertService(); core.http.registerRouteHandlerContext( APP_ID, @@ -87,6 +92,7 @@ export class CasePlugin { caseService: this.caseService, caseConfigureService: this.caseConfigureService, userActionService: this.userActionService, + alertsService: this.alertsService, }) ); @@ -104,24 +110,31 @@ export class CasePlugin { caseService: this.caseService, caseConfigureService: this.caseConfigureService, userActionService: this.userActionService, + alertsService: this.alertsService, }); } public async start(core: CoreStart) { this.log.debug(`Starting Case Workflow`); + this.alertsService!.initialize(core.elasticsearch.client); - const getCaseClientWithRequest = async (request: KibanaRequest) => { + const getCaseClientWithRequestAndContext = async ( + context: RequestHandlerContext, + request: KibanaRequest + ) => { return createCaseClient({ savedObjectsClient: core.savedObjects.getScopedClient(request), request, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, userActionService: this.userActionService!, + alertsService: this.alertsService!, + context, }); }; return { - getCaseClientWithRequest, + getCaseClientWithRequestAndContext, }; } @@ -134,11 +147,13 @@ export class CasePlugin { caseService, caseConfigureService, userActionService, + alertsService, }: { core: CoreSetup; caseService: CaseServiceSetup; caseConfigureService: CaseConfigureServiceSetup; userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; }): IContextProvider, typeof APP_ID> => { return async (context, request) => { const [{ savedObjects }] = await core.getStartServices(); @@ -149,7 +164,9 @@ export class CasePlugin { caseService, caseConfigureService, userActionService, + alertsService, request, + context, }); }, }; 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 index 95856dd75d0ae..645673fdee756 100644 --- 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 @@ -44,6 +44,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T21:54:48.952Z', @@ -78,6 +81,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T22:32:00.900Z', @@ -116,6 +122,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -158,6 +167,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -188,6 +200,9 @@ export const mockCaseNoConnectorId: SavedObject> = { email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T21:54:48.952Z', 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 index 67890599fa417..dcae1c6083eb6 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -5,10 +5,10 @@ */ import { RequestHandlerContext, KibanaRequest } from 'src/core/server'; -import { loggingSystemMock } from 'src/core/server/mocks'; +import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks'; import { actionsClientMock } from '../../../../../actions/server/mocks'; import { createCaseClient } from '../../../client'; -import { CaseService, CaseConfigureService } from '../../../services'; +import { CaseService, CaseConfigureService, AlertService } from '../../../services'; import { getActions } from '../__mocks__/request_responses'; import { authenticationMock } from '../__fixtures__'; @@ -16,6 +16,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { const actionsMock = actionsClientMock.create(); actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); const log = loggingSystemMock.create().get('case'); + const esClientMock = elasticsearchServiceMock.createClusterClient(); const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); @@ -24,18 +25,10 @@ export const createRouteContext = async (client: any, badAuth = false) => { authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), }); const caseConfigureService = await caseConfigureServicePlugin.setup(); - const caseClient = createCaseClient({ - savedObjectsClient: client, - request: {} as KibanaRequest, - caseService, - caseConfigureService, - userActionService: { - postUserActions: jest.fn(), - getUserActions: jest.fn(), - }, - }); + const alertsService = new AlertService(); + alertsService.initialize(esClientMock); - return ({ + const context = ({ core: { savedObjects: { client, @@ -45,5 +38,25 @@ export const createRouteContext = async (client: any, badAuth = false) => { case: { getCaseClient: () => caseClient, }, + securitySolution: { + getAppClient: () => ({ + getSignalsIndex: () => '.siem-signals', + }), + }, } as unknown) as RequestHandlerContext; + + const caseClient = createCaseClient({ + savedObjectsClient: client, + request: {} as KibanaRequest, + caseService, + caseConfigureService, + userActionService: { + postUserActions: jest.fn(), + getUserActions: jest.fn(), + }, + alertsService, + context, + }); + + return context; }; diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index ce35b99750419..209fa11116c56 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -17,6 +17,9 @@ export const newCase: CasePostRequest = { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; export const getActions = (): FindActionResult[] => [ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 08d442bccf2cb..139fb7c5f27a4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -32,7 +32,7 @@ export function initPostCommentApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.addComment({ caseId, comment }), + body: await caseClient.addComment({ caseClient, caseId, comment }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 053f9ec18ab0f..6a6f5653375b8 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -74,6 +74,9 @@ describe('PATCH cases', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -125,6 +128,9 @@ describe('PATCH cases', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -175,6 +181,9 @@ describe('PATCH cases', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 873671a909801..178e40520d9d2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -27,7 +27,7 @@ export function initPatchCasesApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.update({ cases }), + body: await caseClient.update({ caseClient, cases }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 508684b422891..ea59959b0e849 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -42,6 +42,9 @@ describe('POST cases', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }, }); @@ -78,6 +81,9 @@ describe('POST cases', () => { type: '.jira', fields: { issueType: 'Task', priority: 'High', parent: null }, }, + settings: { + syncAlerts: true, + }, }, }); @@ -108,6 +114,9 @@ describe('POST cases', () => { status: CaseStatuses.open, tags: ['defacement'], connector: null, + settings: { + syncAlerts: true, + }, }, }); @@ -130,6 +139,9 @@ describe('POST cases', () => { title: 'Super Bad Security Issue', tags: ['error'], connector: null, + settings: { + syncAlerts: true, + }, }, }); @@ -160,6 +172,9 @@ describe('POST cases', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }, }); @@ -199,6 +214,9 @@ describe('POST cases', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); }); }); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 7654ae5ff0d1a..405da0df17542 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -302,6 +302,9 @@ describe('Utils', () => { comments: [], totalComment: 2, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -341,6 +344,9 @@ describe('Utils', () => { comments: [], totalComment: 0, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -387,6 +393,9 @@ describe('Utils', () => { comments: [], totalComment: 0, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -497,6 +506,9 @@ describe('Utils', () => { comments: [], totalComment: 2, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }); }); }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index d8ee2f90f3d93..6468d4b3aa61d 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -134,6 +134,13 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, + settings: { + properties: { + syncAlerts: { + type: 'boolean', + }, + }, + }, }, }, migrations: caseMigrations, diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index 27c363a40af37..9124314ac3f5e 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -9,16 +9,16 @@ import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; import { ConnectorTypes, CommentType } from '../../common/api'; -interface UnsanitizedCase { +interface UnsanitizedCaseConnector { connector_id: string; } -interface UnsanitizedConfigure { +interface UnsanitizedConfigureConnector { connector_id: string; connector_name: string; } -interface SanitizedCase { +interface SanitizedCaseConnector { connector: { id: string; name: string | null; @@ -27,7 +27,7 @@ interface SanitizedCase { }; } -interface SanitizedConfigure { +interface SanitizedConfigureConnector { connector: { id: string; name: string | null; @@ -42,10 +42,16 @@ interface UserActions { old_value: string; } +interface SanitizedCaseSettings { + settings: { + syncAlerts: boolean; + }; +} + export const caseMigrations = { '7.10.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { const { connector_id, ...attributesWithoutConnectorId } = doc.attributes; return { @@ -62,12 +68,26 @@ export const caseMigrations = { references: doc.references || [], }; }, + '7.11.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + settings: { + syncAlerts: true, + }, + }, + references: doc.references || [], + }; + }, }; export const configureMigrations = { '7.10.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { const { connector_id, connector_name, ...restAttributes } = doc.attributes; return { diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts new file mode 100644 index 0000000000000..4fb98278b8afa --- /dev/null +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; + +import { IClusterClient, KibanaRequest } from 'kibana/server'; +import { CaseStatuses } from '../../../common/api'; + +export type AlertServiceContract = PublicMethodsOf; + +interface UpdateAlertsStatusArgs { + request: KibanaRequest; + ids: string[]; + status: CaseStatuses; + index: string; +} + +export class AlertService { + private isInitialized = false; + private esClient?: IClusterClient; + + constructor() {} + + public initialize(esClient: IClusterClient) { + if (this.isInitialized) { + throw new Error('AlertService already initialized'); + } + + this.isInitialized = true; + this.esClient = esClient; + } + + public async updateAlertsStatus({ request, ids, status, index }: UpdateAlertsStatusArgs) { + if (!this.isInitialized) { + throw new Error('AlertService not initialized'); + } + + // The above check makes sure that esClient is defined. + const result = await this.esClient!.asScoped(request).asCurrentUser.updateByQuery({ + index, + conflicts: 'abort', + body: { + script: { + source: `ctx._source.signal.status = '${status}'`, + lang: 'painless', + }, + query: { ids: { values: ids } }, + }, + ignore_unavailable: true, + }); + + return result; + } +} diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 0ce2b196af471..95bcf87361e07 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -31,6 +31,7 @@ import { readTags } from './tags/read_tags'; export { CaseConfigureService, CaseConfigureServiceSetup } from './configure'; export { CaseUserActionService, CaseUserActionServiceSetup } from './user_actions'; +export { AlertService, AlertServiceContract } from './alerts'; export interface ClientArgs { client: SavedObjectsClientContract; diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 287f80a60ab07..01a8cb09ac2d5 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -4,11 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaseConfigureServiceSetup, CaseServiceSetup, CaseUserActionServiceSetup } from '.'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, + AlertServiceContract, +} from '.'; export type CaseServiceMock = jest.Mocked; export type CaseConfigureServiceMock = jest.Mocked; export type CaseUserActionServiceMock = jest.Mocked; +export type AlertServiceMock = jest.Mocked; export const createCaseServiceMock = (): CaseServiceMock => ({ deleteCase: jest.fn(), @@ -41,3 +47,8 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ getUserActions: jest.fn(), postUserActions: jest.fn(), }); + +export const createAlertServiceMock = (): AlertServiceMock => ({ + initialize: jest.fn(), + updateAlertsStatus: jest.fn(), +}); diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index c9339862b8f24..c7bdc8b10b5a3 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -129,6 +129,7 @@ const userActionFieldsAllowed: UserActionField = [ 'tags', 'title', 'status', + 'settings', ]; export const buildCaseUserActions = ({ diff --git a/x-pack/plugins/case/server/types.ts b/x-pack/plugins/case/server/types.ts index b95060ef30452..d0dfc26aa7b8c 100644 --- a/x-pack/plugins/case/server/types.ts +++ b/x-pack/plugins/case/server/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AppRequestContext } from '../../security_solution/server/types'; import { CaseClient } from './client'; export interface CaseRequestContext { @@ -13,5 +15,8 @@ export interface CaseRequestContext { declare module 'src/core/server' { interface RequestHandlerContext { case?: CaseRequestContext; + // TODO: Remove when triggers_ui do not import case's types. + // PR https://github.com/elastic/kibana/pull/84587. + securitySolution?: AppRequestContext; } } diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 96868fa8cfc3b..f518c606d6959 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -207,6 +207,7 @@ export type ElasticsearchAssetTypeToParts = Record< export interface RegistryDataStream { type: string; + hidden?: boolean; dataset: string; title: string; release: string; @@ -319,7 +320,7 @@ export interface IndexTemplate { mappings: any; aliases: object; }; - data_stream: object; + data_stream: { hidden?: boolean }; composed_of: string[]; _meta: object; } diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 199026da30c11..944f742e54546 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -314,6 +314,7 @@ export async function installTemplate({ pipelineName, packageName, composedOfTemplates, + hidden: dataStream.hidden, }); // TODO: Check return values for errors diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index cc1aa79c7491c..bdff7e0fb3bc6 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -60,6 +60,31 @@ test('adds empty composed_of correctly', () => { expect(template.composed_of).toStrictEqual(composedOfTemplates); }); +test('adds hidden field correctly', () => { + const templateWithHiddenName = 'logs-nginx-access-abcd'; + + const templateWithHidden = getTemplate({ + type: 'logs', + templateName: templateWithHiddenName, + packageName: 'nginx', + mappings: { properties: {} }, + composedOfTemplates: [], + hidden: true, + }); + expect(templateWithHidden.data_stream.hidden).toEqual(true); + + const templateWithoutHiddenName = 'logs-nginx-access-efgh'; + + const templateWithoutHidden = getTemplate({ + type: 'logs', + templateName: templateWithoutHiddenName, + packageName: 'nginx', + mappings: { properties: {} }, + composedOfTemplates: [], + }); + expect(templateWithoutHidden.data_stream.hidden).toEqual(undefined); +}); + test('tests loading base.yml', () => { const ymlPath = path.join(__dirname, '../../fields/tests/base.yml'); const fieldsYML = readFileSync(ymlPath, 'utf-8'); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 8d33180d6262d..d80d54d098db7 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -45,6 +45,7 @@ export function getTemplate({ pipelineName, packageName, composedOfTemplates, + hidden, }: { type: string; templateName: string; @@ -52,8 +53,16 @@ export function getTemplate({ pipelineName?: string | undefined; packageName: string; composedOfTemplates: string[]; + hidden?: boolean; }): IndexTemplate { - const template = getBaseTemplate(type, templateName, mappings, packageName, composedOfTemplates); + const template = getBaseTemplate( + type, + templateName, + mappings, + packageName, + composedOfTemplates, + hidden + ); if (pipelineName) { template.template.settings.index.default_pipeline = pipelineName; } @@ -253,7 +262,8 @@ function getBaseTemplate( templateName: string, mappings: IndexTemplateMappings, packageName: string, - composedOfTemplates: string[] + composedOfTemplates: string[], + hidden?: boolean ): IndexTemplate { // Meta information to identify Ingest Manager's managed templates and indices const _meta = { @@ -324,7 +334,7 @@ function getBaseTemplate( // To be filled with the aliases that we need aliases: {}, }, - data_stream: {}, + data_stream: { hidden }, composed_of: composedOfTemplates, _meta, }; diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index b3c82a8d9d6f0..3ce507c791f0a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -217,7 +217,7 @@ describe('Custom detection rules creation', () => { }); }); -describe('Custom detection rules deletion and edition', () => { +describe.skip('Custom detection rules deletion and edition', () => { beforeEach(() => { esArchiverLoad('custom_rules'); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 755dde9341dca..78bb3a8d2f2f3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -474,6 +474,9 @@ describe('AllCases', () => { username: 'lknope', }, version: 'WzQ3LDFd', + settings: { + syncAlerts: true, + }, }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx index 945458e92bc8a..62ce0cc2cc2f5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; import styled, { css } from 'styled-components'; import { EuiButtonEmpty, @@ -13,6 +13,7 @@ import { EuiDescriptionListTitle, EuiFlexGroup, EuiFlexItem, + EuiIconTip, } from '@elastic/eui'; import { CaseStatuses } from '../../../../../case/common/api'; import * as i18n from '../case_view/translations'; @@ -22,6 +23,8 @@ import { Case } from '../../containers/types'; import { CaseService } from '../../containers/use_get_case_user_actions'; import { StatusContextMenu } from './status_context_menu'; import { getStatusDate, getStatusTitle } from './helpers'; +import { SyncAlertsSwitch } from '../case_settings/sync_alerts_switch'; +import { OnUpdateFields } from '../case_view'; const MyDescriptionList = styled(EuiDescriptionList)` ${({ theme }) => css` @@ -38,7 +41,7 @@ interface CaseActionBarProps { disabled?: boolean; isLoading: boolean; onRefresh: () => void; - onStatusChanged: (status: CaseStatuses) => void; + onUpdateField: (args: OnUpdateFields) => void; } const CaseActionBarComponent: React.FC = ({ caseData, @@ -46,10 +49,27 @@ const CaseActionBarComponent: React.FC = ({ disabled = false, isLoading, onRefresh, - onStatusChanged, + onUpdateField, }) => { const date = useMemo(() => getStatusDate(caseData), [caseData]); const title = useMemo(() => getStatusTitle(caseData.status), [caseData.status]); + const onStatusChanged = useCallback( + (status: CaseStatuses) => + onUpdateField({ + key: 'status', + value: status, + }), + [onUpdateField] + ); + + const onSyncAlertsChanged = useCallback( + (syncAlerts: boolean) => + onUpdateField({ + key: 'settings', + value: { ...caseData.settings, syncAlerts }, + }), + [caseData.settings, onUpdateField] + ); return ( @@ -78,20 +98,41 @@ const CaseActionBarComponent: React.FC = ({ - - - - {i18n.CASE_REFRESH} - - - - - - + + + + + + + + {i18n.STATUS} + + + + + + + + + + + + {i18n.CASE_REFRESH} + + + + + + + ); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx b/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx new file mode 100644 index 0000000000000..ab91f2ae8cdf3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx @@ -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 React, { memo, useCallback, useState } from 'react'; +import { EuiSwitch } from '@elastic/eui'; + +import * as i18n from '../../translations'; + +interface Props { + disabled: boolean; + isSynced?: boolean; + showLabel?: boolean; + onSwitchChange?: (isSynced: boolean) => void; +} + +const SyncAlertsSwitchComponent: React.FC = ({ + disabled, + isSynced = true, + showLabel = false, + onSwitchChange, +}) => { + const [isOn, setIsOn] = useState(isSynced); + + const onChange = useCallback(() => { + if (onSwitchChange) { + onSwitchChange(!isOn); + } + + setIsOn(!isOn); + }, [isOn, onSwitchChange]); + + return ( + + ); +}; + +SyncAlertsSwitchComponent.displayName = 'SyncAlertsSwitchComponent'; + +export const SyncAlertsSwitch = memo(SyncAlertsSwitchComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 0e6226f69fce7..6007038b33ab7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -16,7 +16,7 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../case/common/api'; +import { CaseStatuses, CaseAttributes } from '../../../../../case/common/api'; import { Case, CaseConnector } from '../../containers/types'; import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to'; import { gutterTimeline } from '../../../common/lib/helpers'; @@ -234,6 +234,21 @@ export const CaseComponent = React.memo( onError, }); } + break; + case 'settings': + const settingsUpdate = getTypedPayload(value); + if (caseData.settings !== value) { + updateCaseProperty({ + fetchCaseUserActions, + updateKey: 'settings', + updateValue: settingsUpdate, + updateCase: handleUpdateNewCase, + version: caseData.version, + onSuccess, + onError, + }); + } + break; default: return null; } @@ -397,9 +412,9 @@ export const CaseComponent = React.memo( currentExternalIncident={currentExternalIncident} caseData={caseData} disabled={!userCanCrud} - isLoading={isLoading && updateKey === 'status'} + isLoading={isLoading && (updateKey === 'status' || updateKey === 'settings')} onRefresh={handleRefresh} - onStatusChanged={changeStatus} + onUpdateField={onUpdateField} /> diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx index b2a0f3c351552..67c536f652ec1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx @@ -7,13 +7,13 @@ import React, { memo, useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; import { UseField, useFormData, FieldHook } from '../../../shared_imports'; import { useConnectors } from '../../containers/configure/use_connectors'; import { ConnectorSelector } from '../connector_selector/form'; import { SettingFieldsForm } from '../settings/fields_form'; import { ActionConnector } from '../../containers/types'; import { getConnectorById } from '../configure_cases/utils'; +import { FormProps } from './schema'; interface Props { isLoading: boolean; @@ -21,7 +21,7 @@ interface Props { interface SettingsFieldProps { connectors: ActionConnector[]; - field: FieldHook; + field: FieldHook; isEdit: boolean; } diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx index e64b2b3a05080..3091e6b33d333 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx @@ -25,6 +25,7 @@ const initialCaseValue: FormProps = { title: '', connectorId: 'none', fields: null, + syncAlerts: true, }; describe('CreateCaseForm', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form.tsx index 40db4d792c1c8..308dc63916934 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form.tsx @@ -15,6 +15,7 @@ import { Description } from './description'; import { Tags } from './tags'; import { Connector } from './connector'; import * as i18n from './translations'; +import { SyncAlertsToggle } from './sync_alerts_toggle'; interface ContainerProps { big?: boolean; @@ -61,6 +62,18 @@ export const CreateCaseForm: React.FC = React.memo(({ withSteps = true }) const secondStep = useMemo( () => ({ title: i18n.STEP_TWO_TITLE, + children: ( + + + + ), + }), + [isSubmitting] + ); + + const thirdStep = useMemo( + () => ({ + title: i18n.STEP_THREE_TITLE, children: ( @@ -70,7 +83,11 @@ export const CreateCaseForm: React.FC = React.memo(({ withSteps = true }) [isSubmitting] ); - const allSteps = useMemo(() => [firstStep, secondStep], [firstStep, secondStep]); + const allSteps = useMemo(() => [firstStep, secondStep, thirdStep], [ + firstStep, + secondStep, + thirdStep, + ]); return ( <> @@ -85,6 +102,7 @@ export const CreateCaseForm: React.FC = React.memo(({ withSteps = true }) <> {firstStep.children} {secondStep.children} + {thirdStep.children} )} diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index e11e508b60ebf..4575059a5a6c0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -23,6 +23,7 @@ const initialCaseValue: FormProps = { title: '', connectorId: 'none', fields: null, + syncAlerts: true, }; interface Props { @@ -34,14 +35,21 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { const { caseData, postCase } = usePostCase(); const submitCase = useCallback( - async ({ connectorId: dataConnectorId, fields, ...dataWithoutConnectorId }, isValid) => { + async ( + { connectorId: dataConnectorId, fields, syncAlerts, ...dataWithoutConnectorId }, + isValid + ) => { if (isValid) { const caseConnector = getConnectorById(dataConnectorId, connectors); const connectorToUpdate = caseConnector ? normalizeActionConnector(caseConnector, fields) : getNoneConnector(); - await postCase({ ...dataWithoutConnectorId, connector: connectorToUpdate }); + await postCase({ + ...dataWithoutConnectorId, + connector: connectorToUpdate, + settings: { syncAlerts }, + }); } }, [postCase, connectors] diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx index 29073e7774158..fe5b3bea6445c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx @@ -8,8 +8,9 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { TestProviders } from '../../../common/mock'; +import { CasePostRequest } from '../../../../../case/common/api'; +import { TestProviders } from '../../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; @@ -41,7 +42,7 @@ const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; const postCase = jest.fn(); const sampleTags = ['coke', 'pepsi']; -const sampleData = { +const sampleData: CasePostRequest = { description: 'what a great description', tags: sampleTags, title: 'what a cool title', @@ -51,6 +52,9 @@ const sampleData = { name: 'none', type: ConnectorTypes.none, }, + settings: { + syncAlerts: true, + }, }; const defaultPostCase = { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx b/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx index a336860121c94..34f0bdd051483 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx @@ -6,7 +6,7 @@ import { CasePostRequest, ConnectorTypeFields } from '../../../../../case/common/api'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; -import * as i18n from '../../translations'; +import * as i18n from './translations'; import { OptionalFieldLabel } from './optional_field_label'; const { emptyField } = fieldValidators; @@ -18,9 +18,10 @@ export const schemaTags = { labelAppend: OptionalFieldLabel, }; -export type FormProps = Omit & { +export type FormProps = Omit & { connectorId: string; fields: ConnectorTypeFields['fields']; + syncAlerts: boolean; }; export const schema: FormSchema = { @@ -47,4 +48,10 @@ export const schema: FormSchema = { label: i18n.CONNECTORS, defaultValue: 'none', }, + fields: {}, + syncAlerts: { + helpText: i18n.SYNC_ALERTS_HELP, + type: FIELD_TYPES.TOGGLE, + defaultValue: true, + }, }; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx new file mode 100644 index 0000000000000..0abb2974dd2cb --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx @@ -0,0 +1,37 @@ +/* + * 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 React, { memo } from 'react'; +import { Field, getUseField, useFormData } from '../../../shared_imports'; +import * as i18n from './translations'; + +const CommonUseField = getUseField({ component: Field }); + +interface Props { + isLoading: boolean; +} + +const SyncAlertsToggleComponent: React.FC = ({ isLoading }) => { + const [{ syncAlerts }] = useFormData({ watch: ['syncAlerts'] }); + return ( + + ); +}; + +SyncAlertsToggleComponent.displayName = 'SyncAlertsToggleComponent'; + +export const SyncAlertsToggle = memo(SyncAlertsToggleComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/translations.ts b/x-pack/plugins/security_solution/public/cases/components/create/translations.ts index 38916dbddc7d7..f892e080af782 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/create/translations.ts @@ -17,7 +17,21 @@ export const STEP_ONE_TITLE = i18n.translate( export const STEP_TWO_TITLE = i18n.translate( 'xpack.securitySolution.components.create.stepTwoTitle', + { + defaultMessage: 'Case settings', + } +); + +export const STEP_THREE_TITLE = i18n.translate( + 'xpack.securitySolution.components.create.stepThreeTitle', { defaultMessage: 'External Connector Fields', } ); + +export const SYNC_ALERTS_LABEL = i18n.translate( + 'xpack.securitySolution.components.create.syncAlertsLabel', + { + defaultMessage: 'Sync alert status with case status', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx index 148ad275b756e..be437073e693c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx @@ -9,7 +9,7 @@ import { EuiLink } from '@elastic/eui'; import { APP_ID } from '../../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; -import { getRuleDetailsUrl, useFormatUrl } from '../../../common/components/link_to'; +import { getRuleDetailsUrl } from '../../../common/components/link_to'; import { SecurityPageName } from '../../../app/types'; import { Alert } from '../case_view'; @@ -23,16 +23,15 @@ const AlertCommentEventComponent: React.FC = ({ alert }) => { const ruleName = alert?.rule?.name ?? null; const ruleId = alert?.rule?.id ?? null; const { navigateToApp } = useKibana().services.application; - const { formatUrl } = useFormatUrl(SecurityPageName.detections); const onLinkClick = useCallback( (ev: { preventDefault: () => void }) => { ev.preventDefault(); navigateToApp(`${APP_ID}:${SecurityPageName.detections}`, { - path: formatUrl(getRuleDetailsUrl(ruleId ?? '')), + path: getRuleDetailsUrl(ruleId ?? ''), }); }, - [ruleId, formatUrl, navigateToApp] + [ruleId, navigateToApp] ); return ruleId != null && ruleName != null ? ( diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx index f60993fc9aa02..bec1ab3dd4292 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx @@ -384,6 +384,9 @@ describe('Case Configuration API', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; test('check url, method, signal', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 40312a8713783..f94fb189c90ce 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -76,6 +76,9 @@ export const basicCase: Case = { updatedAt: basicUpdatedAt, updatedBy: elasticUser, version: 'WzQ3LDFd', + settings: { + syncAlerts: true, + }, }; export const basicCasePost: Case = { diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index ec1eaa939fe31..a5c9c65dab62a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -11,6 +11,7 @@ import { CaseConnector, CommentRequest, CaseStatuses, + CaseAttributes, } from '../../../../case/common/api'; export { CaseConnector, ActionConnector } from '../../../../case/common/api'; @@ -63,6 +64,7 @@ export interface Case { updatedAt: string | null; updatedBy: ElasticUser | null; version: string; + settings: CaseAttributes['settings']; } export interface QueryParams { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 44166a14ad292..060ed787c7f4e 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -74,6 +74,9 @@ export const initialData: Case = { updatedAt: null, updatedBy: null, version: '', + settings: { + syncAlerts: true, + }, }; export interface UseGetCase extends CaseState { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx index c4363236a0977..8e8432d0d190c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx @@ -24,6 +24,9 @@ describe('usePostCase', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx index c305399ee02d0..08333416d3c46 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx @@ -19,7 +19,7 @@ import { Case } from './types'; export type UpdateKey = keyof Pick< CasePatchRequest, - 'connector' | 'description' | 'status' | 'tags' | 'title' + 'connector' | 'description' | 'status' | 'tags' | 'title' | 'settings' >; interface NewCaseState { diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts index a79f7a3af18bf..fd217457f9e7d 100644 --- a/x-pack/plugins/security_solution/public/cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/translations.ts @@ -256,3 +256,25 @@ export const IN_PROGRESS_CASES = i18n.translate( defaultMessage: 'In progress cases', } ); + +export const SYNC_ALERTS_SWITCH_LABEL_ON = i18n.translate( + 'xpack.securitySolution.case.settings.syncAlertsSwitchLabelOn', + { + defaultMessage: 'On', + } +); + +export const SYNC_ALERTS_SWITCH_LABEL_OFF = i18n.translate( + 'xpack.securitySolution.case.settings.syncAlertsSwitchLabelOff', + { + defaultMessage: 'Off', + } +); + +export const SYNC_ALERTS_HELP = i18n.translate( + 'xpack.securitySolution.components.create.syncAlertHelpText', + { + defaultMessage: + 'Enabling this option will sync the status of alerts in this case with the case status.', + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts index 7088f094ddcb4..77e975a46d37b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts @@ -31,7 +31,43 @@ export const getPolicyDataForUpdate = ( ): NewPolicyData | Immutable => { // eslint-disable-next-line @typescript-eslint/naming-convention const { id, revision, created_by, created_at, updated_by, updated_at, ...newPolicy } = policy; - return newPolicy; + + // trim custom malware notification string + return { + ...newPolicy, + inputs: (newPolicy as Immutable).inputs.map((input) => ({ + ...input, + config: input.config && { + ...input.config, + policy: { + ...input.config.policy, + value: { + ...input.config.policy.value, + windows: { + ...input.config.policy.value.windows, + popup: { + ...input.config.policy.value.windows.popup, + malware: { + ...input.config.policy.value.windows.popup.malware, + message: input.config.policy.value.windows.popup.malware.message.trim(), + }, + }, + }, + mac: { + ...input.config.policy.value.mac, + popup: { + ...input.config.policy.value.mac.popup, + malware: { + ...input.config.policy.value.mac.popup.malware, + message: input.config.policy.value.mac.popup.malware.message.trim(), + }, + }, + }, + }, + }, + }, + })), + }; }; /** diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index bfa592b1f9c8e..e9c13b23834b1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -293,7 +293,7 @@ describe('Policy Details', () => { policyView = render(); }); - it('malware popup and message customization options are shown', () => { + it('malware popup, message customization options and tooltip are shown', () => { // use query for finding stuff, if it doesn't find it, just returns null const userNotificationCheckbox = policyView.find( 'EuiCheckbox[data-test-subj="malwareUserNotificationCheckbox"]' @@ -301,8 +301,10 @@ describe('Policy Details', () => { const userNotificationCustomMessageTextArea = policyView.find( 'EuiTextArea[data-test-subj="malwareUserNotificationCustomMessage"]' ); + const tooltip = policyView.find('EuiIconTip'); expect(userNotificationCheckbox).toHaveLength(1); expect(userNotificationCustomMessageTextArea).toHaveLength(1); + expect(tooltip).toHaveLength(1); }); }); describe('when the subscription tier is gold or lower', () => { @@ -311,15 +313,17 @@ describe('Policy Details', () => { policyView = render(); }); - it('malware popup and message customization options are hidden', () => { + it('malware popup, message customization options, and tooltip are hidden', () => { const userNotificationCheckbox = policyView.find( 'EuiCheckbox[data-test-subj="malwareUserNotificationCheckbox"]' ); const userNotificationCustomMessageTextArea = policyView.find( 'EuiTextArea[data-test-subj="malwareUserNotificationCustomMessage"]' ); + const tooltip = policyView.find('EuiIconTip'); expect(userNotificationCheckbox).toHaveLength(0); expect(userNotificationCustomMessageTextArea).toHaveLength(0); + expect(tooltip).toHaveLength(0); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index c78455aa8d990..d611c4102e8f8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -18,6 +18,9 @@ import { EuiText, EuiTextArea, htmlIdGenerator, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, } from '@elastic/eui'; import { cloneDeep } from 'lodash'; import { APP_ID } from '../../../../../../../common/constants'; @@ -193,7 +196,7 @@ export const MalwareProtections = React.memo(() => { if (policyDetailsConfig) { const newPayload = cloneDeep(policyDetailsConfig); for (const os of OSes) { - newPayload[os].popup[protection].message = event.target.value.trim(); + newPayload[os].popup[protection].message = event.target.value; } dispatch({ type: 'userChangedPolicyConfig', @@ -252,14 +255,37 @@ export const MalwareProtections = React.memo(() => { {isPlatinumPlus && userNotificationSelected && ( <> - -

- + + +

+ +

+
+
+ + + + + + + } /> -

-
+ + { return new Plugin(context); @@ -41,6 +42,7 @@ export const config: PluginConfigDescriptor = { }; export { ConfigType, Plugin, PluginSetup, PluginStart }; +export { AppClient }; // Exports to be shared with plugins such as x-pack/lists plugin export { deleteTemplate } from './lib/detection_engine/index/delete_template'; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts b/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts index 36f07ef92b5f1..df200b34dc429 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts @@ -38,5 +38,18 @@ export default function createGetTests({ getService }: FtrProviderContext) { fields: null, }); }); + + it('7.11.0 migrates cases settings', async () => { + const { body } = await supertest + .get(`${CASES_URL}/e1900ac0-017f-11eb-93f8-d161651bf509`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).key('settings'); + expect(body.settings).to.eql({ + syncAlerts: true, + }); + }); }); } diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index 6949052df4703..ec79c8a1ca494 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -36,7 +36,7 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); - it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector']`, async () => { + it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector', 'settings]`, async () => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -51,7 +51,14 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.length).to.eql(1); - expect(body[0].action_field).to.eql(['description', 'status', 'tags', 'title', 'connector']); + expect(body[0].action_field).to.eql([ + 'description', + 'status', + 'tags', + 'title', + 'connector', + 'settings', + ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null); expect(body[0].new_value).to.eql(JSON.stringify(postCaseReq)); diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts index 9a45dd541bb56..e0812d01d0fb8 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/basic/tests/connectors/case.ts @@ -391,6 +391,9 @@ export default ({ getService }: FtrProviderContext): void => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -442,6 +445,9 @@ export default ({ getService }: FtrProviderContext): void => { type: '.servicenow', fields: {}, }, + settings: { + syncAlerts: true, + }, }, }; @@ -673,7 +679,53 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - it('should respond with a 400 Bad Request when missing attributes of type alert', async () => { + // TODO: Remove it when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it('should fail adding a comment of type alert', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }) + .expect(200); + + createdActionId = createdAction.id; + + const caseRes = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const params = { + subAction: 'addComment', + subActionParams: { + caseId: caseRes.body.id, + comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert }, + }, + }; + + const caseConnector = await supertest + .post(`/api/actions/action/${createdActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ params }) + .expect(200); + + expect(caseConnector.body).to.eql({ + status: 'error', + actionId: createdActionId, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.type]: expected value to equal [user]', + retry: false, + }); + }); + + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('should respond with a 400 Bad Request when missing attributes of type alert', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') @@ -754,13 +806,15 @@ export default ({ getService }: FtrProviderContext): void => { expect(caseConnector.body).to.eql({ status: 'error', actionId: createdActionId, - message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing\n - [subActionParams.comment.1.type]: expected value to equal [alert]`, + message: `error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [create]\n- [1.subAction]: expected value to equal [update]\n- [2.subActionParams.comment]: types that failed validation:\n - [subActionParams.comment.0.${attribute}]: definition for this key is missing`, retry: false, }); } }); - it('should respond with a 400 Bad Request when adding excess attributes for type alert', async () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('should respond with a 400 Bad Request when adding excess attributes for type alert', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') @@ -892,7 +946,9 @@ export default ({ getService }: FtrProviderContext): void => { }); }); - it('should add a comment of type alert', async () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('should add a comment of type alert', async () => { const { body: createdAction } = await supertest .post('/api/actions/action') .set('kbn-xsrf', 'foo') diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index dac6b2005a9c3..012af6b37f842 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -26,6 +26,9 @@ export const postCaseReq: CasePostRequest = { type: '.none' as ConnectorTypes, fields: null, }, + settings: { + syncAlerts: true, + }, }; export const postCommentUserReq: CommentRequestUserType = {