From 82366720c2a63771485ecd5ea5526eca75138f36 Mon Sep 17 00:00:00 2001 From: ymao1 Date: Wed, 9 Dec 2020 12:14:30 -0500 Subject: [PATCH] [Actions] Disable 'Resolved' action group for ServiceNow, Jira and IBM Resilient action types (#83829) (#85420) * Adding disabled action groups to action type definition * Adding tests * Adding tests * renamed Resolved to Recovered * fixed missing import * fixed buggy default message behaviour * added missing test * fixed typing * fixed resolved in tests * allows alert types to specify their own custom recovery group name * removed unnecesery field on always fires * allows alert types to specify their own custom recovery group * fixed mock alert types throughout unit tests * fixed typing issues * reduce repetition of mock data * fixed alert type list test * support legacy event log alert recovery syntax * added doc * removed unneeded change in jira * correct callback name in siem * renamed resolved to recovered * fixed mistaken rename * Moving to alert plugin * Updating tests * elvated default params to alert concern instead of actions concern * made default params optional * Adding test * Moving where default action params are retrieved * Revert "Moving where default action params are retrieved" This reverts commit 76e7608229ddf22ee251d5dc09ad53279f679131. * Moving where default action params are retrieved * Cleanup * Fixing test * PR fixes Co-authored-by: Gidi Meir Morris Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Gidi Meir Morris Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/disabled_action_groups.test.ts | 19 +++ .../alerts/common/disabled_action_groups.ts | 24 ++++ x-pack/plugins/alerts/common/index.ts | 1 + .../action_form.test.tsx | 126 +++++++++++++++++- .../action_connector_form/action_form.tsx | 3 + .../action_type_form.tsx | 27 +++- .../sections/alert_form/alert_form.tsx | 23 +++- 7 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/alerts/common/disabled_action_groups.test.ts create mode 100644 x-pack/plugins/alerts/common/disabled_action_groups.ts diff --git a/x-pack/plugins/alerts/common/disabled_action_groups.test.ts b/x-pack/plugins/alerts/common/disabled_action_groups.test.ts new file mode 100644 index 00000000000000..96db7bfd8710d0 --- /dev/null +++ b/x-pack/plugins/alerts/common/disabled_action_groups.test.ts @@ -0,0 +1,19 @@ +/* + * 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 { isActionGroupDisabledForActionTypeId } from './disabled_action_groups'; +import { RecoveredActionGroup } from './builtin_action_groups'; + +test('returns false if action group id has no disabled types', () => { + expect(isActionGroupDisabledForActionTypeId('enabledActionGroup', '.jira')).toBeFalsy(); +}); + +test('returns false if action group id does not contains type', () => { + expect(isActionGroupDisabledForActionTypeId(RecoveredActionGroup.id, '.email')).toBeFalsy(); +}); + +test('returns true if action group id does contain type', () => { + expect(isActionGroupDisabledForActionTypeId(RecoveredActionGroup.id, '.jira')).toBeTruthy(); +}); diff --git a/x-pack/plugins/alerts/common/disabled_action_groups.ts b/x-pack/plugins/alerts/common/disabled_action_groups.ts new file mode 100644 index 00000000000000..525a267a278ea5 --- /dev/null +++ b/x-pack/plugins/alerts/common/disabled_action_groups.ts @@ -0,0 +1,24 @@ +/* + * 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 { RecoveredActionGroup } from './builtin_action_groups'; + +const DisabledActionGroupsByActionType: Record = { + [RecoveredActionGroup.id]: ['.jira', '.servicenow', '.resilient'], +}; + +export const DisabledActionTypeIdsForActionGroup: Map = new Map( + Object.entries(DisabledActionGroupsByActionType) +); + +export function isActionGroupDisabledForActionTypeId( + actionGroup: string, + actionTypeId: string +): boolean { + return ( + DisabledActionTypeIdsForActionGroup.has(actionGroup) && + DisabledActionTypeIdsForActionGroup.get(actionGroup)!.includes(actionTypeId) + ); +} diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index 4d0e7bf7eb0bc7..3e551facd98a04 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -13,6 +13,7 @@ export * from './alert_task_instance'; export * from './alert_navigation'; export * from './alert_instance_summary'; export * from './builtin_action_groups'; +export * from './disabled_action_groups'; export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 5b2c8bd63a2f5f..9de3ae21a8ef77 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -11,6 +11,11 @@ import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, Alert, AlertAction } from '../../../types'; import ActionForm from './action_form'; import { useKibana } from '../../../common/lib/kibana'; +import { + RecoveredActionGroup, + isActionGroupDisabledForActionTypeId, +} from '../../../../../alerts/common'; + jest.mock('../../../common/lib/kibana'); jest.mock('../../lib/action_connector_api', () => ({ loadAllActions: jest.fn(), @@ -65,6 +70,21 @@ describe('action_form', () => { actionParamsFields: mockedActionParamsFields, }; + const disabledByActionType = { + id: '.jira', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: mockedActionParamsFields, + }; + const disabledByLicenseActionType = { id: 'disabled-by-license', iconClass: 'test', @@ -112,7 +132,7 @@ describe('action_form', () => { const useKibanaMock = useKibana as jest.Mocked; describe('action_form in alert', () => { - async function setup(customActions?: AlertAction[]) { + async function setup(customActions?: AlertAction[], customRecoveredActionGroup?: string) { const actionTypeRegistry = actionTypeRegistryMock.create(); const { loadAllActions } = jest.requireMock('../../lib/action_connector_api'); @@ -159,6 +179,14 @@ describe('action_form', () => { }, isPreconfigured: false, }, + { + secrets: {}, + id: '.jira', + actionTypeId: disabledByActionType.id, + name: 'Connector with disabled action group', + config: {}, + isPreconfigured: false, + }, ]); const mocks = coreMock.createSetup(); const [ @@ -179,6 +207,7 @@ describe('action_form', () => { actionType, disabledByConfigActionType, disabledByLicenseActionType, + disabledByActionType, preconfiguredOnly, actionTypeWithoutParams, ]); @@ -223,12 +252,24 @@ describe('action_form', () => { context: [{ name: 'contextVar', description: 'context var1' }], }} defaultActionGroupId={'default'} + isActionGroupDisabledForActionType={(actionGroupId: string, actionTypeId: string) => { + const recoveryActionGroupId = customRecoveredActionGroup + ? customRecoveredActionGroup + : 'recovered'; + return isActionGroupDisabledForActionTypeId( + actionGroupId === recoveryActionGroupId ? RecoveredActionGroup.id : actionGroupId, + actionTypeId + ); + }} setActionIdByIndex={(id: string, index: number) => { initialAlert.actions[index].id = id; }} actionGroups={[ { id: 'default', name: 'Default', defaultActionMessage }, - { id: 'recovered', name: 'Recovered' }, + { + id: customRecoveredActionGroup ? customRecoveredActionGroup : 'recovered', + name: customRecoveredActionGroup ? 'I feel better' : 'Recovered', + }, ]} setActionGroupIdByIndex={(group: string, index: number) => { initialAlert.actions[index].group = group; @@ -280,6 +321,14 @@ describe('action_form', () => { enabledInLicense: false, minimumLicenseRequired: 'gold', }, + { + id: '.jira', + name: 'Disabled by action type', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, { id: actionTypeWithoutParams.id, name: 'Action type without params', @@ -342,11 +391,13 @@ describe('action_form', () => { Array [ Object { "data-test-subj": "addNewActionConnectorActionGroup-0-option-default", + "disabled": false, "inputDisplay": "Default", "value": "default", }, Object { "data-test-subj": "addNewActionConnectorActionGroup-0-option-recovered", + "disabled": false, "inputDisplay": "Recovered", "value": "recovered", }, @@ -354,6 +405,77 @@ describe('action_form', () => { `); }); + it('renders disabled action groups for selected action type', async () => { + const wrapper = await setup([ + { + group: 'recovered', + id: 'test', + actionTypeId: disabledByActionType.id, + params: { + message: '', + }, + }, + ]); + const actionOption = wrapper.find(`[data-test-subj=".jira-ActionTypeSelectOption"]`); + actionOption.first().simulate('click'); + const actionGroupsSelect = wrapper.find( + `[data-test-subj="addNewActionConnectorActionGroup-1"]` + ); + expect((actionGroupsSelect.first().props() as any).options).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "addNewActionConnectorActionGroup-1-option-default", + "disabled": false, + "inputDisplay": "Default", + "value": "default", + }, + Object { + "data-test-subj": "addNewActionConnectorActionGroup-1-option-recovered", + "disabled": true, + "inputDisplay": "Recovered (Not Currently Supported)", + "value": "recovered", + }, + ] + `); + }); + + it('renders disabled action groups for custom recovered action groups', async () => { + const wrapper = await setup( + [ + { + group: 'iHaveRecovered', + id: 'test', + actionTypeId: disabledByActionType.id, + params: { + message: '', + }, + }, + ], + 'iHaveRecovered' + ); + const actionOption = wrapper.find(`[data-test-subj=".jira-ActionTypeSelectOption"]`); + actionOption.first().simulate('click'); + const actionGroupsSelect = wrapper.find( + `[data-test-subj="addNewActionConnectorActionGroup-1"]` + ); + expect((actionGroupsSelect.first().props() as any).options).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "addNewActionConnectorActionGroup-1-option-default", + "disabled": false, + "inputDisplay": "Default", + "value": "default", + }, + Object { + "data-test-subj": "addNewActionConnectorActionGroup-1-option-iHaveRecovered", + "disabled": true, + "inputDisplay": "I feel better (Not Currently Supported)", + "value": "iHaveRecovered", + }, + ] + `); + }); + it('renders available connectors for the selected action type', async () => { const wrapper = await setup(); const actionOption = wrapper.find( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 0337f6879e24a5..1cb1a689861922 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -59,6 +59,7 @@ export interface ActionAccordionFormProps { setHasActionsWithBrokenConnector?: (value: boolean) => void; actionTypeRegistry: ActionTypeRegistryContract; getDefaultActionParams?: DefaultActionParamsGetter; + isActionGroupDisabledForActionType?: (actionGroupId: string, actionTypeId: string) => boolean; } interface ActiveActionConnectorState { @@ -81,6 +82,7 @@ export const ActionForm = ({ setHasActionsWithBrokenConnector, actionTypeRegistry, getDefaultActionParams, + isActionGroupDisabledForActionType, }: ActionAccordionFormProps) => { const { http, @@ -345,6 +347,7 @@ export const ActionForm = ({ actionGroups={actionGroups} defaultActionMessage={defaultActionMessage} defaultParams={getDefaultActionParams?.(actionItem.actionTypeId, actionItem.group)} + isActionGroupDisabledForActionType={isActionGroupDisabledForActionType} setActionGroupIdByIndex={setActionGroupIdByIndex} onAddConnector={() => { setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index d68f66f373135d..9a721b2f2bed00 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -60,6 +60,7 @@ export type ActionTypeFormProps = { connectors: ActionConnector[]; actionTypeRegistry: ActionTypeRegistryContract; defaultParams: DefaultActionParams; + isActionGroupDisabledForActionType?: (actionGroupId: string, actionTypeId: string) => boolean; } & Pick< ActionAccordionFormProps, | 'defaultActionGroupId' @@ -94,6 +95,7 @@ export const ActionTypeForm = ({ actionGroups, setActionGroupIdByIndex, actionTypeRegistry, + isActionGroupDisabledForActionType, defaultParams, }: ActionTypeFormProps) => { const { @@ -145,6 +147,28 @@ export const ActionTypeForm = ({ const actionType = actionTypesIndex[actionItem.actionTypeId]; + const actionGroupDisplay = ( + actionGroupId: string, + actionGroupName: string, + actionTypeId: string + ): string => + isActionGroupDisabledForActionType + ? isActionGroupDisabledForActionType(actionGroupId, actionTypeId) + ? i18n.translate( + 'xpack.triggersActionsUI.sections.alertForm.addNewActionConnectorActionGroup.display', + { + defaultMessage: '{actionGroupName} (Not Currently Supported)', + values: { actionGroupName }, + } + ) + : actionGroupName + : actionGroupName; + + const isActionGroupDisabled = (actionGroupId: string, actionTypeId: string): boolean => + isActionGroupDisabledForActionType + ? isActionGroupDisabledForActionType(actionGroupId, actionTypeId) + : false; + const optionsList = connectors .filter( (connectorItem) => @@ -191,7 +215,8 @@ export const ActionTypeForm = ({ data-test-subj={`addNewActionConnectorActionGroup-${index}`} options={actionGroups.map(({ id: value, name }) => ({ value, - inputDisplay: name, + inputDisplay: actionGroupDisplay(value, name, actionItem.actionTypeId), + disabled: isActionGroupDisabled(value, actionItem.actionTypeId), 'data-test-subj': `addNewActionConnectorActionGroup-${index}-option-${value}`, }))} valueOfSelected={selectedActionGroup.id} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 3dc483eb3d8b89..c7b7997e555910 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -55,7 +55,12 @@ import { } from '../../../types'; import { getTimeOptions } from '../../../common/lib/get_time_options'; import { ActionForm } from '../action_connector_form'; -import { AlertActionParam, ALERTS_FEATURE_ID } from '../../../../../alerts/common'; +import { + AlertActionParam, + ALERTS_FEATURE_ID, + RecoveredActionGroup, + isActionGroupDisabledForActionTypeId, +} from '../../../../../alerts/common'; import { hasAllPrivilege, hasShowActionsCapability } from '../../lib/capabilities'; import { SolutionFilter } from './solution_filter'; import './alert_form.scss'; @@ -192,6 +197,7 @@ export const AlertForm = ({ setDefaultActionGroupId(index.get(alert.alertTypeId)!.defaultActionGroupId); } setAlertTypesIndex(index); + const availableAlertTypesResult = getAvailableAlertTypes(alertTypesResult); setAvailableAlertTypes(availableAlertTypesResult); @@ -331,6 +337,18 @@ export const AlertForm = ({ const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; + const isActionGroupDisabledForActionType = useCallback( + (alertType: AlertType, actionGroupId: string, actionTypeId: string): boolean => { + return isActionGroupDisabledForActionTypeId( + actionGroupId === alertType?.recoveryActionGroup?.id + ? RecoveredActionGroup.id + : actionGroupId, + actionTypeId + ); + }, + [] + ); + const AlertParamsExpressionComponent = alertTypeModel ? alertTypeModel.alertParamsExpression : null; @@ -513,6 +531,9 @@ export const AlertForm = ({ setHasActionsWithBrokenConnector={setHasActionsWithBrokenConnector} messageVariables={selectedAlertType.actionVariables} defaultActionGroupId={defaultActionGroupId} + isActionGroupDisabledForActionType={(actionGroupId: string, actionTypeId: string) => + isActionGroupDisabledForActionType(selectedAlertType, actionGroupId, actionTypeId) + } actionGroups={selectedAlertType.actionGroups.map((actionGroup) => actionGroup.id === selectedAlertType.recoveryActionGroup.id ? {