diff --git a/x-pack/plugins/cases/common/utils/user_actions.ts b/x-pack/plugins/cases/common/utils/user_actions.ts index 7de0d7066eaed..ef8d2bb425e7d 100644 --- a/x-pack/plugins/cases/common/utils/user_actions.ts +++ b/x-pack/plugins/cases/common/utils/user_actions.ts @@ -16,3 +16,12 @@ export function isUpdateConnector(action?: string, actionFields?: string[]): boo export function isPush(action?: string, actionFields?: string[]): boolean { return action === 'push-to-service' && actionFields != null && actionFields.includes('pushed'); } + +export function isCreateComment(action?: string, actionFields?: string[]): boolean { + return ( + action === 'create' && + actionFields !== null && + actionFields !== undefined && + actionFields.includes('comment') + ); +} diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx index 5c0d64857d0cb..fa4f8147c031f 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.test.tsx @@ -8,16 +8,19 @@ import React from 'react'; import { mount } from 'enzyme'; -import { CaseStatuses, ConnectorTypes } from '../../../common/api'; +import { CaseStatuses, CommentRequestAlertType, ConnectorTypes } from '../../../common/api'; import { basicPush, getUserAction } from '../../containers/mock'; import { getLabelTitle, getPushedServiceLabelTitle, getConnectorLabelTitle, toStringArray, + getRuleId, + getRuleName, } from './helpers'; import { connectorsMock } from '../../containers/configure/mock'; import * as i18n from './translations'; +import { Ecs } from '../../containers/types'; describe('User action tree helpers', () => { const connectors = connectorsMock; @@ -245,4 +248,131 @@ describe('User action tree helpers', () => { expect(res).toEqual(['100']); }); }); + + describe('rule getters', () => { + describe.each([ + ['getRuleId', getRuleId], + ['getRuleName', getRuleName], + ])('%s null checks', (name, funcToExec) => { + it('returns null if the comment field is an empty string', () => { + const comment = { + rule: { + id: '', + name: '', + }, + } as unknown as CommentRequestAlertType; + + expect(funcToExec(comment)).toBeNull(); + }); + + it('returns null if the comment field is an empty string in an array', () => { + const comment = { + rule: { + id: [''], + name: [''], + }, + } as unknown as CommentRequestAlertType; + + expect(funcToExec(comment)).toBeNull(); + }); + + it('returns null if the comment does not have a rule field', () => { + const comment = {} as unknown as CommentRequestAlertType; + + expect(funcToExec(comment)).toBeNull(); + }); + + it('returns null if the signals and alert field is an empty string', () => { + const comment = {} as unknown as CommentRequestAlertType; + const alert = { + signal: { rule: { id: '', name: '' } }, + kibana: { alert: { rule: { uuid: '', name: '' } } }, + } as unknown as Ecs; + + expect(funcToExec(comment, alert)).toBeNull(); + }); + }); + + describe.each([ + ['getRuleId', getRuleId, '1'], + ['getRuleName', getRuleName, 'Rule name1'], + ])('%s', (name, funcToExec, expectedResult) => { + it('returns the first entry in the comment field', () => { + const comment = { + rule: { + id: ['1', '2'], + name: ['Rule name1', 'Rule name2'], + }, + } as unknown as CommentRequestAlertType; + + expect(funcToExec(comment)).toEqual(expectedResult); + }); + + it('returns signal field', () => { + const comment = {} as unknown as CommentRequestAlertType; + const alert = { signal: { rule: { id: '1', name: 'Rule name1' } } } as unknown as Ecs; + + expect(funcToExec(comment, alert)).toEqual(expectedResult); + }); + + it('returns kibana alert field', () => { + const comment = {} as unknown as CommentRequestAlertType; + const alert = { + kibana: { alert: { rule: { uuid: '1', name: 'Rule name1' } } }, + } as unknown as Ecs; + + expect(funcToExec(comment, alert)).toEqual(expectedResult); + }); + + it('returns signal field even when kibana alert field is defined', () => { + const comment = {} as unknown as CommentRequestAlertType; + const alert = { + signal: { rule: { id: '1', name: 'Rule name1' } }, + kibana: { alert: { rule: { uuid: 'rule id1', name: 'other rule name1' } } }, + } as unknown as Ecs; + + expect(funcToExec(comment, alert)).toEqual(expectedResult); + }); + + it('returns the first entry in the signals field', () => { + const comment = {} as unknown as CommentRequestAlertType; + const alert = { + signal: { rule: { id: '1', name: 'Rule name1' } }, + kibana: { alert: { rule: { uuid: 'rule id1', name: 'other rule name1' } } }, + } as unknown as Ecs; + + expect(funcToExec(comment, alert)).toEqual(expectedResult); + }); + + it('returns the alert field if the signals field is an empty string', () => { + const comment = {} as unknown as CommentRequestAlertType; + const alert = { + signal: { rule: { id: '', name: '' } }, + kibana: { alert: { rule: { uuid: '1', name: 'Rule name1' } } }, + } as unknown as Ecs; + + expect(funcToExec(comment, alert)).toEqual(expectedResult); + }); + + it('returns the alert field if the signals field is an empty string in an array', () => { + const comment = {} as unknown as CommentRequestAlertType; + const alert = { + signal: { rule: { id: [''], name: [''] } }, + kibana: { alert: { rule: { uuid: '1', name: 'Rule name1' } } }, + } as unknown as Ecs; + + expect(funcToExec(comment, alert)).toEqual(expectedResult); + }); + + it('returns the alert field first item if the signals field is an empty string in an array', () => { + const comment = {} as unknown as CommentRequestAlertType; + const alert = { + signal: { rule: { id: [''], name: [''] } }, + kibana: { alert: { rule: { uuid: ['1', '2'], name: ['Rule name1', 'Rule name2'] } } }, + } as unknown as Ecs; + + expect(funcToExec(comment, alert)).toEqual(expectedResult); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx index d1e119cdd6bdf..cd13d33bf8dd4 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { get, isEmpty } from 'lodash'; import { EuiFlexGroup, EuiFlexItem, @@ -13,10 +14,11 @@ import { EuiCommentProps, EuiToken, } from '@elastic/eui'; +import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils/technical_field_names'; import React, { useContext } from 'react'; import classNames from 'classnames'; import { ThemeContext } from 'styled-components'; -import { Comment } from '../../../common/ui/types'; +import { Comment, Ecs } from '../../../common/ui/types'; import { CaseFullExternalService, ActionConnector, @@ -24,6 +26,7 @@ import { CommentType, CommentRequestActionsType, noneConnectorId, + CommentRequestAlertType, } from '../../../common/api'; import { CaseUserActions } from '../../containers/types'; import { CaseServices } from '../../containers/use_get_case_user_actions'; @@ -458,3 +461,51 @@ export interface Alert { signal: Signal; [key: string]: unknown; } + +export const getFirstItem = (items?: string | string[] | null): string | null => { + return Array.isArray(items) ? items[0] : items ?? null; +}; + +export const getRuleId = (comment: CommentRequestAlertType, alertData?: Ecs): string | null => + getRuleField({ + commentRuleField: comment?.rule?.id, + alertData, + signalRuleFieldPath: 'signal.rule.id', + kibanaAlertFieldPath: ALERT_RULE_UUID, + }); + +export const getRuleName = (comment: CommentRequestAlertType, alertData?: Ecs): string | null => + getRuleField({ + commentRuleField: comment?.rule?.name, + alertData, + signalRuleFieldPath: 'signal.rule.name', + kibanaAlertFieldPath: ALERT_RULE_NAME, + }); + +const getRuleField = ({ + commentRuleField, + alertData, + signalRuleFieldPath, + kibanaAlertFieldPath, +}: { + commentRuleField: string | string[] | null | undefined; + alertData: Ecs | undefined; + signalRuleFieldPath: string; + kibanaAlertFieldPath: string; +}): string | null => { + const field = + getNonEmptyField(commentRuleField) ?? + getNonEmptyField(get(alertData, signalRuleFieldPath)) ?? + getNonEmptyField(get(alertData, kibanaAlertFieldPath)); + + return field; +}; + +export const getNonEmptyField = (field: string | string[] | undefined | null): string | null => { + const firstItem = getFirstItem(field); + if (firstItem == null || isEmpty(firstItem)) { + return null; + } + + return firstItem; +}; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx index 82d378731f978..3d243b856b0e4 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx @@ -12,10 +12,9 @@ import { EuiCommentList, EuiCommentProps, } from '@elastic/eui'; -import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils/technical_field_names'; import classNames from 'classnames'; -import { get, isEmpty } from 'lodash'; +import { isEmpty } from 'lodash'; import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import styled from 'styled-components'; @@ -49,6 +48,9 @@ import { RuleDetailsNavigation, ActionsNavigation, getActionAttachment, + getNonEmptyField, + getRuleId, + getRuleName, } from './helpers'; import { UserActionAvatar } from './user_action_avatar'; import { UserActionMarkdown } from './user_action_markdown'; @@ -404,33 +406,16 @@ export const UserActionTree = React.memo( isRight(AlertCommentRequestRt.decode(comment)) && comment.type === CommentType.alert ) { - // TODO: clean this up - const alertId = Array.isArray(comment.alertId) - ? comment.alertId.length > 0 - ? comment.alertId[0] - : '' - : comment.alertId; - - const alertIndex = Array.isArray(comment.index) - ? comment.index.length > 0 - ? comment.index[0] - : '' - : comment.index; - - if (isEmpty(alertId)) { + const alertId = getNonEmptyField(comment.alertId); + const alertIndex = getNonEmptyField(comment.index); + + if (!alertId || !alertIndex) { return comments; } - const ruleId = - comment?.rule?.id ?? - manualAlertsData[alertId]?.signal?.rule?.id?.[0] ?? - get(manualAlertsData[alertId], ALERT_RULE_UUID)[0] ?? - null; - const ruleName = - comment?.rule?.name ?? - manualAlertsData[alertId]?.signal?.rule?.name?.[0] ?? - get(manualAlertsData[alertId], ALERT_RULE_NAME)[0] ?? - null; + const alertField: Ecs | undefined = manualAlertsData[alertId]; + const ruleId = getRuleId(comment, alertField); + const ruleName = getRuleName(comment, alertField); return [ ...comments, diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts index 385c1c5945a11..30d13a188ed3b 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.test.ts @@ -9,6 +9,7 @@ import { createCommentsMigrations, mergeMigrationFunctionMaps, migrateByValueLensVisualizations, + removeRuleInformation, stringifyCommentWithoutTrailingNewline, } from './comments'; import { @@ -29,6 +30,7 @@ import { MigrateFunctionsObject, } from '../../../../../../src/plugins/kibana_utils/common'; import { SerializableRecord } from '@kbn/utility-types'; +import { CommentType } from '../../../common/api'; describe('comments migrations', () => { const migrations = createCommentsMigrations({ @@ -396,4 +398,79 @@ describe('comments migrations', () => { ); }); }); + + describe('removeRuleInformation', () => { + it('does not modify non-alert comment', () => { + const doc = { + id: '123', + attributes: { + type: 'user', + }, + type: 'abc', + references: [], + }; + + expect(removeRuleInformation(doc)).toEqual(doc); + }); + + it('sets the rule fields to null', () => { + const doc = { + id: '123', + type: 'abc', + attributes: { + type: CommentType.alert, + rule: { + id: '123', + name: 'hello', + }, + }, + }; + + expect(removeRuleInformation(doc)).toEqual({ + ...doc, + attributes: { ...doc.attributes, rule: { id: null, name: null } }, + references: [], + }); + }); + + it('sets the rule fields to null for a generated alert', () => { + const doc = { + id: '123', + type: 'abc', + attributes: { + type: CommentType.generatedAlert, + rule: { + id: '123', + name: 'hello', + }, + }, + }; + + expect(removeRuleInformation(doc)).toEqual({ + ...doc, + attributes: { ...doc.attributes, rule: { id: null, name: null } }, + references: [], + }); + }); + + it('preserves the references field', () => { + const doc = { + id: '123', + type: 'abc', + attributes: { + type: CommentType.alert, + rule: { + id: '123', + name: 'hello', + }, + }, + references: [{ id: '123', name: 'hi', type: 'awesome' }], + }; + + expect(removeRuleInformation(doc)).toEqual({ + ...doc, + attributes: { ...doc.attributes, rule: { id: null, name: null } }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts index 0af9db13fce40..5ab1dab784f56 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/comments.ts @@ -98,6 +98,15 @@ export const createCommentsMigrations = ( ): SavedObjectSanitizedDoc => { return addOwnerToSO(doc); }, + /* + * This is to fix the issue here: https://github.com/elastic/kibana/issues/123089 + * Instead of migrating the rule information in the references array which was risky for 8.0 + * we decided to remove the information since the UI will do the look up for the rule information if + * the backend returns it as null. + * + * The downside is it incurs extra query overhead. + **/ + '8.0.0': removeRuleInformation, }; return mergeMigrationFunctionMaps(commentsMigrations, embeddableMigrations); @@ -175,3 +184,29 @@ export const mergeMigrationFunctionMaps = ( return mergeWith({ ...obj1 }, obj2, customizer); }; + +export const removeRuleInformation = ( + doc: SavedObjectUnsanitizedDoc> +): SavedObjectSanitizedDoc => { + if ( + doc.attributes.type === CommentType.alert || + doc.attributes.type === CommentType.generatedAlert + ) { + return { + ...doc, + attributes: { + ...doc.attributes, + rule: { + id: null, + name: null, + }, + }, + references: doc.references ?? [], + }; + } + + return { + ...doc, + references: doc.references ?? [], + }; +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts index 3d0cff814b7d4..4e41e4aea5d8b 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.test.ts @@ -7,7 +7,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { cloneDeep, omit } from 'lodash'; import { + SavedObject, SavedObjectMigrationContext, SavedObjectSanitizedDoc, SavedObjectsMigrationLogger, @@ -20,7 +22,7 @@ import { createExternalService, createJiraConnector, } from '../../services/test_utils'; -import { userActionsConnectorIdMigration } from './user_actions'; +import { removeRuleInformation, userActionsConnectorIdMigration } from './user_actions'; const create_7_14_0_userAction = ( params: { @@ -600,4 +602,168 @@ describe('user action migrations', () => { }); }); }); + + describe('removeRuleInformation', () => { + let context: jest.Mocked; + + beforeEach(() => { + context = migrationMocks.createContext(); + }); + + it('does not modify non-alert user action', () => { + const doc = { + id: '123', + attributes: { + action: 'create', + action_field: ['description'], + }, + type: 'abc', + references: [], + }; + + expect(removeRuleInformation(doc, context)).toEqual(doc); + }); + + it('does not modify the document when it fails to decode the new_value field', () => { + const doc = { + id: '123', + attributes: { + action: 'create', + action_field: ['comment'], + new_value: '{"type":"alert",', + }, + type: 'abc', + references: [], + }; + + expect(removeRuleInformation(doc, context)).toEqual(doc); + + const log = context.log as jest.Mocked; + expect(log.error.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed to migrate user action alerts with doc id: 123 version: 8.0.0 error: Unexpected end of JSON input", + Object { + "migrations": Object { + "userAction": Object { + "id": "123", + }, + }, + }, + ] + `); + }); + + it('does not modify the document when new_value is null', () => { + const doc = { + id: '123', + attributes: { + action: 'create', + action_field: ['comment'], + new_value: null, + }, + type: 'abc', + references: [], + }; + + expect(removeRuleInformation(doc, context)).toEqual(doc); + }); + + it('does not modify the document when new_value is undefined', () => { + const doc = { + id: '123', + attributes: { + action: 'create', + action_field: ['comment'], + }, + type: 'abc', + references: [], + }; + + expect(removeRuleInformation(doc, context)).toEqual(doc); + }); + + it('does not modify the document when the comment type is not an alert', () => { + const doc = { + id: '123', + attributes: { + action: 'create', + action_field: ['comment'], + new_value: + '{"type":"not_an_alert","alertId":"4eb4cd05b85bc65c7b9f22b776e0136f970f7538eb0d1b2e6e8c7d35b2e875cb","index":".internal.alerts-security.alerts-default-000001","rule":{"id":"43104810-7875-11ec-abc6-6f72e72f6004","name":"A rule"},"owner":"securitySolution"}', + }, + type: 'abc', + references: [], + }; + + expect(removeRuleInformation(doc, context)).toEqual(doc); + }); + + it('sets the rule fields to null', () => { + const doc = { + id: '123', + attributes: { + action: 'create', + action_field: ['comment'], + new_value: + '{"type":"alert","alertId":"4eb4cd05b85bc65c7b9f22b776e0136f970f7538eb0d1b2e6e8c7d35b2e875cb","index":".internal.alerts-security.alerts-default-000001","rule":{"id":"43104810-7875-11ec-abc6-6f72e72f6004","name":"A rule"},"owner":"securitySolution"}', + }, + type: 'abc', + references: [], + }; + + const newDoc = removeRuleInformation(doc, context) as SavedObject<{ new_value: string }>; + ensureRuleFieldsAreNull(newDoc); + + expect(docWithoutNewValue(newDoc)).toEqual(docWithoutNewValue(doc)); + }); + + it('sets the rule fields to null for a generated alert', () => { + const doc = { + id: '123', + attributes: { + action: 'create', + action_field: ['comment'], + new_value: + '{"type":"generated_alert","alertId":"4eb4cd05b85bc65c7b9f22b776e0136f970f7538eb0d1b2e6e8c7d35b2e875cb","index":".internal.alerts-security.alerts-default-000001","rule":{"id":"43104810-7875-11ec-abc6-6f72e72f6004","name":"A rule"},"owner":"securitySolution"}', + }, + type: 'abc', + references: [], + }; + + const newDoc = removeRuleInformation(doc, context) as SavedObject<{ new_value: string }>; + ensureRuleFieldsAreNull(newDoc); + + expect(docWithoutNewValue(newDoc)).toEqual(docWithoutNewValue(doc)); + }); + + it('preserves the references field', () => { + const doc = { + id: '123', + attributes: { + action: 'create', + action_field: ['comment'], + new_value: + '{"type":"generated_alert","alertId":"4eb4cd05b85bc65c7b9f22b776e0136f970f7538eb0d1b2e6e8c7d35b2e875cb","index":".internal.alerts-security.alerts-default-000001","rule":{"id":"43104810-7875-11ec-abc6-6f72e72f6004","name":"A rule"},"owner":"securitySolution"}', + }, + type: 'abc', + references: [{ id: '123', name: 'hi', type: 'awesome' }], + }; + + const newDoc = removeRuleInformation(doc, context) as SavedObject<{ new_value: string }>; + ensureRuleFieldsAreNull(newDoc); + + expect(docWithoutNewValue(newDoc)).toEqual(docWithoutNewValue(doc)); + }); + }); }); + +const docWithoutNewValue = (doc: {}) => { + const copyOfDoc = cloneDeep(doc); + return omit(copyOfDoc, 'attributes.new_value'); +}; + +const ensureRuleFieldsAreNull = (doc: SavedObject<{ new_value: string }>) => { + const decodedNewValue = JSON.parse(doc.attributes.new_value); + + expect(decodedNewValue.rule).toEqual({ id: null, name: null }); +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts index 4d8395eb189fc..d46db45d4ca6c 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/user_actions.ts @@ -13,8 +13,13 @@ import { SavedObjectSanitizedDoc, SavedObjectMigrationContext, } from '../../../../../../src/core/server'; -import { isPush, isUpdateConnector, isCreateConnector } from '../../../common/utils/user_actions'; -import { ConnectorTypes } from '../../../common/api'; +import { + isPush, + isUpdateConnector, + isCreateConnector, + isCreateComment, +} from '../../../common/utils/user_actions'; +import { CommentRequestAlertType, CommentType, ConnectorTypes } from '../../../common/api'; import { extractConnectorIdFromJson } from '../../services/user_actions/transform'; import { UserActionFieldType } from '../../services/user_actions/types'; @@ -26,7 +31,11 @@ interface UserActions { old_value: string; } -interface UserActionUnmigratedConnectorDocument { +/** + * An interface for the values we need from a json blob style user action to determine what type of + * user action it is. + */ +interface TypedAndValueUserAction { action?: string; action_field?: string[]; new_value?: string | null; @@ -34,7 +43,7 @@ interface UserActionUnmigratedConnectorDocument { } export function userActionsConnectorIdMigration( - doc: SavedObjectUnsanitizedDoc, + doc: SavedObjectUnsanitizedDoc, context: SavedObjectMigrationContext ): SavedObjectSanitizedDoc { const originalDocWithReferences = { ...doc, references: doc.references ?? [] }; @@ -67,7 +76,7 @@ function isConnectorUserAction(action?: string, actionFields?: string[]): boolea } function formatDocumentWithConnectorReferences( - doc: SavedObjectUnsanitizedDoc + doc: SavedObjectUnsanitizedDoc ): SavedObjectSanitizedDoc { const { new_value, old_value, action, action_field, ...restAttributes } = doc.attributes; const { references = [] } = doc; @@ -101,6 +110,79 @@ function formatDocumentWithConnectorReferences( }; } +export function removeRuleInformation( + doc: SavedObjectUnsanitizedDoc, + context: SavedObjectMigrationContext +): SavedObjectSanitizedDoc { + const originalDocWithReferences = { ...doc, references: doc.references ?? [] }; + + try { + const { new_value, action, action_field } = doc.attributes; + + const decodedNewValueData = decodeNewValue(new_value); + + if (!isAlertUserAction(action, action_field, decodedNewValueData)) { + return originalDocWithReferences; + } + + const encodedValue = JSON.stringify({ + ...decodedNewValueData, + rule: { + id: null, + name: null, + }, + }); + + return { + ...doc, + attributes: { + ...doc.attributes, + new_value: encodedValue, + }, + references: doc.references ?? [], + }; + } catch (error) { + logError({ + id: doc.id, + context, + error, + docType: 'user action alerts', + docKey: 'userAction', + }); + + return originalDocWithReferences; + } +} + +function decodeNewValue(data?: string | null): unknown | null { + if (data === undefined || data === null) { + return null; + } + + return JSON.parse(data); +} + +function isAlertUserAction( + action?: string, + actionFields?: string[], + newValue?: unknown | null +): newValue is AlertCommentOptional { + return isCreateComment(action, actionFields) && isAlertObject(newValue); +} + +type AlertCommentOptional = Partial; + +function isAlertObject(data?: unknown | null): boolean { + const unsafeAlertData = data as AlertCommentOptional; + + return ( + unsafeAlertData !== undefined && + unsafeAlertData !== null && + (unsafeAlertData.type === CommentType.generatedAlert || + unsafeAlertData.type === CommentType.alert) + ); +} + export const userActionsMigrations = { '7.10.0': (doc: SavedObjectUnsanitizedDoc): SavedObjectSanitizedDoc => { const { action_field, new_value, old_value, ...restAttributes } = doc.attributes; @@ -146,4 +228,13 @@ export const userActionsMigrations = { return addOwnerToSO(doc); }, '7.16.0': userActionsConnectorIdMigration, + /* + * This is to fix the issue here: https://github.com/elastic/kibana/issues/123089 + * Instead of migrating the rule information in the references array which was risky for 8.0 + * we decided to remove the information since the UI will do the look up for the rule information if + * the backend returns it as null. + * + * The downside is it incurs extra query overhead. + **/ + '8.0.0': removeRuleInformation, }; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts index 67e30987fabac..0711b78159d07 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts @@ -11,12 +11,15 @@ import { CASES_URL, SECURITY_SOLUTION_OWNER, } from '../../../../../../plugins/cases/common/constants'; -import { getComment } from '../../../../common/lib/utils'; +import { deleteAllCaseItems, getComment } from '../../../../common/lib/utils'; +import { CommentResponseAlertsType } from '../../../../../../plugins/cases/common/api'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const es = getService('es'); + const kibanaServer = getService('kibanaServer'); describe('migrations', () => { describe('7.11.0', () => { @@ -59,5 +62,42 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(comment.owner).to.be(SECURITY_SOLUTION_OWNER); }); }); + + describe('8.0.0', () => { + before(async () => { + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/7.13.2/alerts.json' + ); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/7.13.2/alerts.json' + ); + await deleteAllCaseItems(es); + }); + + it('removes the rule information from alert attachments', async () => { + const comment = (await getComment({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + commentId: 'ee59cdd0-cf9d-11eb-a603-13e7747d215c', + })) as CommentResponseAlertsType; + + expect(comment).to.have.property('rule'); + expect(comment.rule.id).to.be(null); + expect(comment.rule.name).to.be(null); + }); + + it('does not modify non-alert attachments', async () => { + const comment = (await getComment({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + commentId: 'ae59cdd0-cf9d-11eb-a603-13e7747d215c', + })) as CommentResponseAlertsType; + + expect(comment).to.not.have.property('rule'); + }); + }); }); } diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts index 2dc4f740a6819..a77a3401897fb 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts @@ -11,16 +11,19 @@ import { CASES_URL, SECURITY_SOLUTION_OWNER, } from '../../../../../../plugins/cases/common/constants'; -import { getCaseUserActions } from '../../../../common/lib/utils'; +import { deleteAllCaseItems, getCaseUserActions } from '../../../../common/lib/utils'; import { CaseUserActionResponse, CaseUserActionsResponse, + CommentType, } from '../../../../../../plugins/cases/common/api'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const es = getService('es'); + const kibanaServer = getService('kibanaServer'); describe('migrations', () => { describe('7.10.0', () => { @@ -215,6 +218,53 @@ export default function createGetTests({ getService }: FtrProviderContext) { }); }); }); + + describe('8.0.0', () => { + before(async () => { + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/7.13.2/alerts.json' + ); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/7.13.2/alerts.json' + ); + await deleteAllCaseItems(es); + }); + + it('removes the rule information from alert user action', async () => { + const userActions = await getCaseUserActions({ + supertest, + caseID: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + const userAction = getUserActionById(userActions, 'a5509250-cf9d-11eb-a603-13e7747d215c')!; + + const newValDecoded = JSON.parse(userAction.new_value!); + expect(newValDecoded.type).to.be(CommentType.alert); + expect(newValDecoded.alertId).to.be( + '4eb4cd05b85bc65c7b9f22b776e0136f970f7538eb0d1b2e6e8c7d35b2e875cb' + ); + expect(newValDecoded.index).to.be('.internal.alerts-security.alerts-default-000001'); + expect(newValDecoded.rule.id).to.be(null); + expect(newValDecoded.rule.name).to.be(null); + }); + + it('does not modify non-alert attachments', async () => { + const userActions = await getCaseUserActions({ + supertest, + caseID: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + const userAction = getUserActionById(userActions, 'e5509250-cf9d-11eb-a603-13e7747d215c')!; + + const newValDecoded = JSON.parse(userAction.new_value!); + expect(newValDecoded).to.not.have.property('rule'); + expect(newValDecoded.type).to.be('individual'); + expect(newValDecoded.title).to.be('A case'); + }); + }); }); } diff --git a/x-pack/test/functional/fixtures/kbn_archiver/cases/7.13.2/alerts.json b/x-pack/test/functional/fixtures/kbn_archiver/cases/7.13.2/alerts.json new file mode 100644 index 0000000000000..e7976cb49938d --- /dev/null +++ b/x-pack/test/functional/fixtures/kbn_archiver/cases/7.13.2/alerts.json @@ -0,0 +1,202 @@ +{ + "attributes": { + "closed_at": null, + "closed_by": null, + "connector": { + "fields": [ + { + "key": "issueType", + "value": "10002" + }, + { + "key": "parent", + "value": null + }, + { + "key": "priority", + "value": null + } + ], + "id": "d68508f0-cf9d-11eb-a603-13e7747d215c", + "name": "Test Jira", + "type": ".jira" + }, + "created_at": "2021-06-17T18:57:41.682Z", + "created_by": { + "email": null, + "full_name": "j@j.com", + "username": "711621466" + }, + "description": "asdf", + "external_service": { + "connector_id": "d68508f0-cf9d-11eb-a603-13e7747d215c", + "connector_name": "Test Jira", + "external_id": "10106", + "external_title": "TPN-99", + "external_url": "https://cases-testing.atlassian.net/browse/TPN-99", + "pushed_at": "2021-06-17T18:57:45.524Z", + "pushed_by": { + "email": null, + "full_name": "j@j.com", + "username": "711621466" + } + }, + "settings": { + "syncAlerts": true + }, + "status": "open", + "tags": [ + "some tag" + ], + "title": "A case", + "type": "individual", + "updated_at": "2021-06-17T18:57:58.037Z", + "updated_by": { + "email": null, + "full_name": "j@j.com", + "username": "711621466" + } + }, + "id": "e49ad6e0-cf9d-11eb-a603-13e7747d215c", + "coreMigrationVersion": "7.13.2", + "migrationVersion": { + "cases": "7.12.0" + }, + "references": [ + ], + "type": "cases", + "updated_at": "2021-06-17T18:57:58.076Z" +} + +{ + "attributes": { + "associationType": "case", + "alertId": "123", + "index": "index", + "rule": {"id": "id", "name": "name"}, + "created_at": "2021-06-17T18:57:58.037Z", + "created_by": { + "email": null, + "full_name": "j@j.com", + "username": "711621466" + }, + "pushed_at": null, + "pushed_by": null, + "type": "alert", + "updated_at": null, + "updated_by": null + }, + "coreMigrationVersion": "7.13.2", + "migrationVersion": { + "cases-comments": "7.12.0" + }, + "id": "ee59cdd0-cf9d-11eb-a603-13e7747d215c", + "references": [ + { + "id": "e49ad6e0-cf9d-11eb-a603-13e7747d215c", + "name": "associated-cases", + "type": "cases" + } + ], + "type": "cases-comments", + "updated_at": "2021-06-17T18:57:58.087Z" +} + +{ + "attributes": { + "associationType": "case", + "comment": "a comment", + "created_at": "2021-06-17T18:57:58.037Z", + "created_by": { + "email": null, + "full_name": "j@j.com", + "username": "711621466" + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null + }, + "coreMigrationVersion": "7.13.2", + "migrationVersion": { + "cases-comments": "7.12.0" + }, + "id": "ae59cdd0-cf9d-11eb-a603-13e7747d215c", + "references": [ + { + "id": "e49ad6e0-cf9d-11eb-a603-13e7747d215c", + "name": "associated-cases", + "type": "cases" + } + ], + "type": "cases-comments", + "updated_at": "2021-06-17T18:57:58.087Z" +} + +{ + "attributes": { + "action": "create", + "action_at": "2021-06-17T18:57:41.682Z", + "action_by": { + "email": null, + "full_name": "j@j.com", + "username": "711621466" + }, + "action_field": [ + "description", + "status", + "tags", + "title", + "connector", + "settings" + ], + "new_value": "{\"type\":\"individual\",\"title\":\"A case\",\"tags\":[\"some tag\"],\"description\":\"asdf\",\"connector\":{\"id\":\"d68508f0-cf9d-11eb-a603-13e7747d215c\",\"name\":\"Test Jira\",\"type\":\".jira\",\"fields\":{\"issueType\":\"10002\",\"parent\":null,\"priority\":null}},\"settings\":{\"syncAlerts\":true}}", + "old_value": null + }, + "coreMigrationVersion": "7.13.2", + "migrationVersion": { + "cases-user-actions": "7.10.0" + }, + "id": "e5509250-cf9d-11eb-a603-13e7747d215c", + "references": [ + { + "id": "e49ad6e0-cf9d-11eb-a603-13e7747d215c", + "name": "associated-cases", + "type": "cases" + } + ], + "type": "cases-user-actions", + "updated_at": "2021-06-17T18:57:42.925Z" +} + +{ + "attributes": { + "action": "create", + "action_at": "2021-06-17T18:57:41.682Z", + "action_by": { + "email": null, + "full_name": "j@j.com", + "username": "711621466" + }, + "action_field": [ + "comment" + ], + "new_value": "{\"type\":\"alert\",\"alertId\":\"4eb4cd05b85bc65c7b9f22b776e0136f970f7538eb0d1b2e6e8c7d35b2e875cb\",\"index\":\".internal.alerts-security.alerts-default-000001\",\"rule\":{\"id\":\"43104810-7875-11ec-abc6-6f72e72f6004\",\"name\":\"A rule\"}}", + "old_value": null + }, + "coreMigrationVersion": "7.13.2", + "migrationVersion": { + "cases-user-actions": "7.10.0" + }, + "id": "a5509250-cf9d-11eb-a603-13e7747d215c", + "references": [ + { + "id": "e49ad6e0-cf9d-11eb-a603-13e7747d215c", + "name": "associated-cases", + "type": "cases" + } + ], + "type": "cases-user-actions", + "updated_at": "2021-06-17T18:57:42.925Z" +}