diff --git a/x-pack/examples/triggers_actions_ui_example/kibana.jsonc b/x-pack/examples/triggers_actions_ui_example/kibana.jsonc index 9de248572a5bb..e3e6c8adc4a97 100644 --- a/x-pack/examples/triggers_actions_ui_example/kibana.jsonc +++ b/x-pack/examples/triggers_actions_ui_example/kibana.jsonc @@ -4,7 +4,7 @@ "owner": "@elastic/response-ops", "plugin": { "id": "triggersActionsUiExample", - "server": false, + "server": true, "browser": true, "requiredPlugins": [ "triggersActionsUi", @@ -12,10 +12,9 @@ "alerting", "developerExamples", "kibanaReact", - "cases" - ], - "optionalPlugins": [ - "spaces" + "cases", + "actions" ], + "optionalPlugins": ["spaces"] } } diff --git a/x-pack/examples/triggers_actions_ui_example/public/connector_types/system_log_example/system_log_example.tsx b/x-pack/examples/triggers_actions_ui_example/public/connector_types/system_log_example/system_log_example.tsx new file mode 100644 index 0000000000000..6992cda11c09f --- /dev/null +++ b/x-pack/examples/triggers_actions_ui_example/public/connector_types/system_log_example/system_log_example.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { + ActionTypeModel as ConnectorTypeModel, + GenericValidationResult, +} from '@kbn/triggers-actions-ui-plugin/public/types'; +import { SystemLogActionParams } from '../types'; + +export function getConnectorType(): ConnectorTypeModel { + return { + id: '.system-log-example', + iconClass: 'logsApp', + selectMessage: i18n.translate( + 'xpack.stackConnectors.components.systemLogExample.selectMessageText', + { + defaultMessage: 'Example of a system action that sends logs to the Kibana server', + } + ), + actionTypeTitle: i18n.translate( + 'xpack.stackConnectors.components.serverLog.connectorTypeTitle', + { + defaultMessage: 'Send to System log - Example', + } + ), + validateParams: ( + actionParams: SystemLogActionParams + ): Promise>> => { + const errors = { + message: new Array(), + }; + const validationResult = { errors }; + if (!actionParams.message?.length) { + errors.message.push( + i18n.translate( + 'xpack.stackConnectors.components.serverLog.error.requiredServerLogMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return Promise.resolve(validationResult); + }, + actionConnectorFields: null, + actionParamsFields: lazy(() => import('./system_log_example_params')), + isSystemActionType: true, + }; +} diff --git a/x-pack/examples/triggers_actions_ui_example/public/connector_types/system_log_example/system_log_example_params.tsx b/x-pack/examples/triggers_actions_ui_example/public/connector_types/system_log_example/system_log_example_params.tsx new file mode 100644 index 0000000000000..199b86b3ac5ce --- /dev/null +++ b/x-pack/examples/triggers_actions_ui_example/public/connector_types/system_log_example/system_log_example_params.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public'; +import { SystemLogActionParams } from '../types'; + +export const ServerLogParamsFields: React.FunctionComponent< + ActionParamsProps +> = ({ + actionParams, + editAction, + index, + errors, + messageVariables, + defaultMessage, + useDefaultMessage, +}) => { + const { message } = actionParams; + + const [[isUsingDefault, defaultMessageUsed], setDefaultMessageUsage] = useState< + [boolean, string | undefined] + >([false, defaultMessage]); + // This params component is derived primarily from server_log_params.tsx, see that file and its + // corresponding unit tests for details on functionality + useEffect(() => { + if ( + useDefaultMessage || + !actionParams?.message || + (isUsingDefault && + actionParams?.message === defaultMessageUsed && + defaultMessageUsed !== defaultMessage) + ) { + setDefaultMessageUsage([true, defaultMessage]); + editAction('message', defaultMessage, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultMessage]); + + return ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServerLogParamsFields as default }; diff --git a/x-pack/plugins/alerting/server/application/alerts_filter_query/constants.ts b/x-pack/examples/triggers_actions_ui_example/public/connector_types/types/index.ts similarity index 57% rename from x-pack/plugins/alerting/server/application/alerts_filter_query/constants.ts rename to x-pack/examples/triggers_actions_ui_example/public/connector_types/types/index.ts index bce6890c22f2c..98181b856c3fa 100644 --- a/x-pack/plugins/alerting/server/application/alerts_filter_query/constants.ts +++ b/x-pack/examples/triggers_actions_ui_example/public/connector_types/types/index.ts @@ -5,9 +5,6 @@ * 2.0. */ -export const filterStateStore = { - APP_STATE: 'appState', - GLOBAL_STATE: 'globalState', -} as const; - -export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore]; +export interface SystemLogActionParams { + message: string; +} diff --git a/x-pack/examples/triggers_actions_ui_example/public/plugin.tsx b/x-pack/examples/triggers_actions_ui_example/public/plugin.tsx index 0ee31733b6a06..ee5c3229cb713 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/plugin.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/plugin.tsx @@ -23,6 +23,7 @@ import { } from '@kbn/triggers-actions-ui-plugin/public/types'; import { SortCombinations } from '@elastic/elasticsearch/lib/api/types'; import { EuiDataGridColumn } from '@elastic/eui'; +import { getConnectorType as getSystemLogExampleConnectorType } from './connector_types/system_log_example/system_log_example'; export interface TriggersActionsUiExamplePublicSetupDeps { alerting: AlertingSetup; @@ -145,6 +146,8 @@ export class TriggersActionsUiExamplePlugin }; alertsTableConfigurationRegistry.register(config); + + triggersActionsUi.actionTypeRegistry.register(getSystemLogExampleConnectorType()); } public stop() {} diff --git a/x-pack/examples/triggers_actions_ui_example/server/connector_types/system_log_example.ts b/x-pack/examples/triggers_actions_ui_example/server/connector_types/system_log_example.ts new file mode 100644 index 0000000000000..e952a7ed01195 --- /dev/null +++ b/x-pack/examples/triggers_actions_ui_example/server/connector_types/system_log_example.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; + +import { LogMeta } from '@kbn/core/server'; +import type { + ActionType as ConnectorType, + ActionTypeExecutorOptions as ConnectorTypeExecutorOptions, + ActionTypeExecutorResult as ConnectorTypeExecutorResult, +} from '@kbn/actions-plugin/server/types'; +import { + AlertingConnectorFeatureId, + UptimeConnectorFeatureId, +} from '@kbn/actions-plugin/common/connector_feature_config'; +import { ConnectorAdapter } from '@kbn/alerting-plugin/server'; + +// see: https://en.wikipedia.org/wiki/Unicode_control_characters +// but don't include tabs (0x09), they're fine +const CONTROL_CHAR_PATTERN = /[\x00-\x08]|[\x0A-\x1F]|[\x7F-\x9F]|[\u2028-\u2029]/g; + +// replaces control characters in string with ;, but leaves tabs +function withoutControlCharacters(s: string): string { + return s.replace(CONTROL_CHAR_PATTERN, ';'); +} + +export type ServerLogConnectorType = ConnectorType<{}, {}, ActionParamsType>; +export type ServerLogConnectorTypeExecutorOptions = ConnectorTypeExecutorOptions< + {}, + {}, + ActionParamsType +>; + +// params definition + +export type ActionParamsType = TypeOf; + +const ParamsSchema = schema.object({ + message: schema.string(), +}); + +export const ConnectorTypeId = '.system-log-example'; +// connector type definition +export function getConnectorType(): ServerLogConnectorType { + return { + id: ConnectorTypeId, + isSystemActionType: true, + minimumLicenseRequired: 'gold', // Third party action types require at least gold + name: i18n.translate('xpack.stackConnectors.systemLogExample.title', { + defaultMessage: 'System log - example', + }), + supportedFeatureIds: [AlertingConnectorFeatureId, UptimeConnectorFeatureId], + validate: { + config: { schema: schema.object({}, { defaultValue: {} }) }, + secrets: { schema: schema.object({}, { defaultValue: {} }) }, + params: { + schema: ParamsSchema, + }, + }, + executor, + }; +} + +export const connectorAdapter: ConnectorAdapter = { + connectorTypeId: ConnectorTypeId, + ruleActionParamsSchema: ParamsSchema, + buildActionParams: ({ alerts, rule, params, spaceId, ruleUrl }) => { + return { ...params }; + }, +}; + +// action executor + +async function executor( + execOptions: ServerLogConnectorTypeExecutorOptions +): Promise> { + const { actionId, params, logger } = execOptions; + const sanitizedMessage = withoutControlCharacters(params.message); + try { + logger.info(`SYSTEM ACTION EXAMPLE Server log: ${sanitizedMessage}`); + } catch (err) { + const message = i18n.translate('xpack.stackConnectors.serverLog.errorLoggingErrorMessage', { + defaultMessage: 'error logging message', + }); + return { + status: 'error', + message, + serviceMessage: err.message, + actionId, + }; + } + + return { status: 'ok', actionId }; +} diff --git a/x-pack/examples/triggers_actions_ui_example/server/index.ts b/x-pack/examples/triggers_actions_ui_example/server/index.ts new file mode 100644 index 0000000000000..6479960a80604 --- /dev/null +++ b/x-pack/examples/triggers_actions_ui_example/server/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializer } from '@kbn/core/server'; + +export const plugin: PluginInitializer = async () => { + const { TriggersActionsUiExamplePlugin } = await import('./plugin'); + return new TriggersActionsUiExamplePlugin(); +}; diff --git a/x-pack/examples/triggers_actions_ui_example/server/plugin.ts b/x-pack/examples/triggers_actions_ui_example/server/plugin.ts new file mode 100644 index 0000000000000..6d55bbb2cb55d --- /dev/null +++ b/x-pack/examples/triggers_actions_ui_example/server/plugin.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Plugin, CoreSetup } from '@kbn/core/server'; + +import { PluginSetupContract as ActionsSetup } from '@kbn/actions-plugin/server'; +import { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/server'; + +import { + getConnectorType as getSystemLogExampleConnectorType, + connectorAdapter as systemLogConnectorAdapter, +} from './connector_types/system_log_example'; + +// this plugin's dependencies +export interface TriggersActionsUiExampleDeps { + alerting: AlertingSetup; + actions: ActionsSetup; +} +export class TriggersActionsUiExamplePlugin + implements Plugin +{ + public setup(core: CoreSetup, { actions, alerting }: TriggersActionsUiExampleDeps) { + actions.registerType(getSystemLogExampleConnectorType()); + alerting.registerConnectorAdapter(systemLogConnectorAdapter); + } + + public start() {} + public stop() {} +} diff --git a/x-pack/examples/triggers_actions_ui_example/tsconfig.json b/x-pack/examples/triggers_actions_ui_example/tsconfig.json index f999d2709a662..64e9254eadc95 100644 --- a/x-pack/examples/triggers_actions_ui_example/tsconfig.json +++ b/x-pack/examples/triggers_actions_ui_example/tsconfig.json @@ -23,5 +23,8 @@ "@kbn/data-plugin", "@kbn/i18n-react", "@kbn/shared-ux-router", + "@kbn/i18n", + "@kbn/actions-plugin", + "@kbn/config-schema", ] } diff --git a/x-pack/plugins/actions/server/action_type_registry.test.ts b/x-pack/plugins/actions/server/action_type_registry.test.ts index f759712d6b45f..dfc42e42b0b34 100644 --- a/x-pack/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/plugins/actions/server/action_type_registry.test.ts @@ -495,7 +495,7 @@ describe('actionTypeRegistry', () => { expect(actionTypeRegistry.isActionExecutable('my-slack1', 'foo')).toEqual(true); }); - test('should return false when isActionTypeEnabled is false and isLicenseValidForActionType is true and it has system connectors', async () => { + test('should return true when isActionTypeEnabled is false and isLicenseValidForActionType is true and it has system connectors', async () => { mockedActionsConfig.isActionTypeEnabled.mockReturnValue(false); mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true }); @@ -504,7 +504,7 @@ describe('actionTypeRegistry', () => { 'system-connector-test.system-action', 'system-action-type' ) - ).toEqual(false); + ).toEqual(true); }); test('should call isLicenseValidForActionType of the license state with notifyUsage false by default', async () => { diff --git a/x-pack/plugins/actions/server/action_type_registry.ts b/x-pack/plugins/actions/server/action_type_registry.ts index a1ac1c2d3ac21..4e6f9aa86482d 100644 --- a/x-pack/plugins/actions/server/action_type_registry.ts +++ b/x-pack/plugins/actions/server/action_type_registry.ts @@ -92,7 +92,11 @@ export class ActionTypeRegistry { (connector) => connector.id === actionId ); - return actionTypeEnabled || (!actionTypeEnabled && inMemoryConnector?.isPreconfigured === true); + return ( + actionTypeEnabled || + (!actionTypeEnabled && + (inMemoryConnector?.isPreconfigured === true || inMemoryConnector?.isSystemAction === true)) + ); } /** diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.mock.ts b/x-pack/plugins/actions/server/actions_client/actions_client.mock.ts index 5a369272617a2..d58476738b9be 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.mock.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.mock.ts @@ -18,6 +18,7 @@ const createActionsClientMock = () => { delete: jest.fn(), update: jest.fn(), getAll: jest.fn(), + getAllSystemConnectors: jest.fn(), getBulk: jest.fn(), getOAuthAccessToken: jest.fn(), execute: jest.fn(), diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts index ff5258ef43cca..8f08bbb4b2d5a 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.test.ts @@ -2797,7 +2797,7 @@ describe('execute()', () => { }); expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ - actionTypeId: 'my-action-type', + actionTypeId: '.cases', operation: 'execute', additionalPrivileges: ['test/create'], }); @@ -2930,7 +2930,7 @@ describe('execute()', () => { }); expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ - actionTypeId: 'my-action-type', + actionTypeId: '.cases', operation: 'execute', additionalPrivileges: ['test/create'], }); diff --git a/x-pack/plugins/actions/server/actions_client/actions_client.ts b/x-pack/plugins/actions/server/actions_client/actions_client.ts index a5314ab2d7d3d..1187898006f9e 100644 --- a/x-pack/plugins/actions/server/actions_client/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client/actions_client.ts @@ -91,6 +91,7 @@ import { } from '../lib/get_execution_log_aggregation'; import { connectorFromSavedObject, isConnectorDeprecated } from '../application/connector/lib'; import { ListTypesParams } from '../application/connector/methods/list_types/types'; +import { getAllSystemConnectors } from '../application/connector/methods/get_all/get_all'; interface ActionUpdate { name: string; @@ -418,6 +419,13 @@ export class ActionsClient { return getAll({ context: this.context, includeSystemActions }); } + /** + * Get all system connectors + */ + public async getAllSystemConnectors(): Promise { + return getAllSystemConnectors({ context: this.context }); + } + /** * Get bulk actions with in-memory list */ @@ -691,7 +699,7 @@ export class ActionsClient { let actionTypeId: string | undefined; try { - if (this.isPreconfigured(actionId)) { + if (this.isPreconfigured(actionId) || this.isSystemAction(actionId)) { const connector = this.context.inMemoryConnectors.find( (inMemoryConnector) => inMemoryConnector.id === actionId ); diff --git a/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.test.ts b/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.test.ts index 2032653712a59..6a87cb65daa6b 100644 --- a/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.test.ts +++ b/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.test.ts @@ -113,8 +113,152 @@ describe('getAll()', () => { getEventLogClient.mockResolvedValue(eventLogClient); }); - describe('authorization', () => { - function getAllOperation(): ReturnType { + describe('getAll()', () => { + describe('authorization', () => { + function getAllOperation(): ReturnType { + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'type', + attributes: { + name: 'test', + config: { + foo: 'bar', + }, + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + scopedClusterClient.asInternalUser.search.mockResponse( + // @ts-expect-error not full search response + { + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + } + ); + + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + inMemoryConnectors: [ + { + id: 'testPreconfigured', + actionTypeId: '.slack', + secrets: {}, + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, + name: 'test', + config: { + foo: 'bar', + }, + }, + ], + connectorTokenClient: connectorTokenClientMock.create(), + getEventLogClient, + }); + return actionsClient.getAll(); + } + + test('ensures user is authorised to get the type of action', async () => { + await getAllOperation(); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + }); + + test('throws when user is not authorised to create the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get all actions`) + ); + + await expect(getAllOperation()).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get all actions]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + }); + }); + + describe('auditLogger', () => { + test('logs audit event when searching connectors', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'type', + attributes: { + name: 'test', + isMissingSecrets: false, + config: { + foo: 'bar', + }, + }, + score: 1, + references: [], + }, + ], + }); + + scopedClusterClient.asInternalUser.search.mockResponse( + // @ts-expect-error not full search response + { + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + } + ); + + await actionsClient.getAll(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_find', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to search connectors', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.getAll()).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_find', + outcome: 'failure', + }), + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + + test('calls unsecuredSavedObjectsClient with parameters and returns inMemoryConnectors correctly', async () => { const expectedResult = { total: 1, per_page: 10, @@ -125,6 +269,7 @@ describe('getAll()', () => { type: 'type', attributes: { name: 'test', + isMissingSecrets: false, config: { foo: 'bar', }, @@ -141,6 +286,7 @@ describe('getAll()', () => { aggregations: { '1': { doc_count: 6 }, testPreconfigured: { doc_count: 2 }, + 'system-connector-.cases': { doc_count: 2 }, }, } ); @@ -169,34 +315,54 @@ describe('getAll()', () => { foo: 'bar', }, }, + /** + * System actions will not + * be returned from getAll + * if no options are provided + */ + { + id: 'system-connector-.cases', + actionTypeId: '.cases', + name: 'System action: .cases', + config: {}, + secrets: {}, + isDeprecated: false, + isMissingSecrets: false, + isPreconfigured: false, + isSystemAction: true, + }, ], connectorTokenClient: connectorTokenClientMock.create(), getEventLogClient, }); - return actionsClient.getAll(); - } - - test('ensures user is authorised to get the type of action', async () => { - await getAllOperation(); - expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); - }); - - test('throws when user is not authorised to create the type of action', async () => { - authorization.ensureAuthorized.mockRejectedValue( - new Error(`Unauthorized to get all actions`) - ); - await expect(getAllOperation()).rejects.toMatchInlineSnapshot( - `[Error: Unauthorized to get all actions]` - ); + const result = await actionsClient.getAll(); - expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + expect(result).toEqual([ + { + id: '1', + name: 'test', + isMissingSecrets: false, + config: { foo: 'bar' }, + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + referencedByCount: 6, + }, + { + id: 'testPreconfigured', + actionTypeId: '.slack', + name: 'test', + isPreconfigured: true, + isSystemAction: false, + isDeprecated: false, + referencedByCount: 2, + }, + ]); }); - }); - describe('auditLogger', () => { - test('logs audit event when searching connectors', async () => { - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + test('get system actions correctly', async () => { + const expectedResult = { total: 1, per_page: 10, page: 1, @@ -215,345 +381,329 @@ describe('getAll()', () => { references: [], }, ], - }); + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); scopedClusterClient.asInternalUser.search.mockResponse( // @ts-expect-error not full search response { aggregations: { '1': { doc_count: 6 }, testPreconfigured: { doc_count: 2 }, + 'system-connector-.cases': { doc_count: 2 }, }, } ); - await actionsClient.getAll(); - - expect(auditLogger.log).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - action: 'connector_find', - outcome: 'success', - }), - kibana: { saved_object: { id: '1', type: 'action' } }, - }) - ); - }); - - test('logs audit event when not authorised to search connectors', async () => { - authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); - - await expect(actionsClient.getAll()).rejects.toThrow(); - - expect(auditLogger.log).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - action: 'connector_find', - outcome: 'failure', - }), - error: { code: 'Error', message: 'Unauthorized' }, - }) - ); - }); - }); - - test('calls unsecuredSavedObjectsClient with parameters and returns inMemoryConnectors correctly', async () => { - const expectedResult = { - total: 1, - per_page: 10, - page: 1, - saved_objects: [ - { - id: '1', - type: 'type', - attributes: { + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + inMemoryConnectors: [ + { + id: 'testPreconfigured', + actionTypeId: '.slack', + secrets: {}, + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, name: 'test', - isMissingSecrets: false, config: { foo: 'bar', }, }, - score: 1, - references: [], + { + id: 'system-connector-.cases', + actionTypeId: '.cases', + name: 'System action: .cases', + config: {}, + secrets: {}, + isDeprecated: false, + isMissingSecrets: false, + isPreconfigured: false, + isSystemAction: true, + }, + ], + connectorTokenClient: connectorTokenClientMock.create(), + getEventLogClient, + }); + + const result = await actionsClient.getAll({ includeSystemActions: true }); + + expect(result).toEqual([ + { + actionTypeId: '.cases', + id: 'system-connector-.cases', + isDeprecated: false, + isPreconfigured: false, + isSystemAction: true, + name: 'System action: .cases', + referencedByCount: 2, }, - ], - }; - unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); - scopedClusterClient.asInternalUser.search.mockResponse( - // @ts-expect-error not full search response - { - aggregations: { - '1': { doc_count: 6 }, - testPreconfigured: { doc_count: 2 }, - 'system-connector-.cases': { doc_count: 2 }, + { + id: '1', + name: 'test', + isMissingSecrets: false, + config: { foo: 'bar' }, + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + referencedByCount: 6, }, - } - ); - - actionsClient = new ActionsClient({ - logger, - actionTypeRegistry, - unsecuredSavedObjectsClient, - scopedClusterClient, - kibanaIndices, - actionExecutor, - ephemeralExecutionEnqueuer, - bulkExecutionEnqueuer, - request, - authorization: authorization as unknown as ActionsAuthorization, - inMemoryConnectors: [ { id: 'testPreconfigured', actionTypeId: '.slack', - secrets: {}, + name: 'test', isPreconfigured: true, - isDeprecated: false, isSystemAction: false, - name: 'test', - config: { - foo: 'bar', - }, - }, - /** - * System actions will not - * be returned from getAll - * if no options are provided - */ - { - id: 'system-connector-.cases', - actionTypeId: '.cases', - name: 'System action: .cases', - config: {}, - secrets: {}, isDeprecated: false, - isMissingSecrets: false, - isPreconfigured: false, - isSystemAction: true, + referencedByCount: 2, }, - ], - connectorTokenClient: connectorTokenClientMock.create(), - getEventLogClient, + ]); }); - const result = await actionsClient.getAll(); - - expect(result).toEqual([ - { - id: '1', - name: 'test', - isMissingSecrets: false, - config: { foo: 'bar' }, - isPreconfigured: false, - isDeprecated: false, - isSystemAction: false, - referencedByCount: 6, - }, - { - id: 'testPreconfigured', - actionTypeId: '.slack', - name: 'test', - isPreconfigured: true, - isSystemAction: false, - isDeprecated: false, - referencedByCount: 2, - }, - ]); - }); - - test('get system actions correctly', async () => { - const expectedResult = { - total: 1, - per_page: 10, - page: 1, - saved_objects: [ + test('validates connectors before return', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'type', + attributes: { + name: 'test', + isMissingSecrets: false, + config: { + foo: 'bar', + }, + }, + score: 1, + references: [], + }, + ], + }); + scopedClusterClient.asInternalUser.search.mockResponse( + // @ts-expect-error not full search response { - id: '1', - type: 'type', - attributes: { + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + } + ); + + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + inMemoryConnectors: [ + { + id: 'testPreconfigured', + actionTypeId: '.slack', + secrets: {}, + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, name: 'test', - isMissingSecrets: false, config: { foo: 'bar', }, }, - score: 1, - references: [], - }, - ], - }; - unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); - scopedClusterClient.asInternalUser.search.mockResponse( - // @ts-expect-error not full search response - { - aggregations: { - '1': { doc_count: 6 }, - testPreconfigured: { doc_count: 2 }, - 'system-connector-.cases': { doc_count: 2 }, - }, - } - ); + ], + connectorTokenClient: connectorTokenClientMock.create(), + getEventLogClient, + }); - actionsClient = new ActionsClient({ - logger, - actionTypeRegistry, - unsecuredSavedObjectsClient, - scopedClusterClient, - kibanaIndices, - actionExecutor, - ephemeralExecutionEnqueuer, - bulkExecutionEnqueuer, - request, - authorization: authorization as unknown as ActionsAuthorization, - inMemoryConnectors: [ + const result = await actionsClient.getAll({ includeSystemActions: true }); + expect(result).toEqual([ { - id: 'testPreconfigured', - actionTypeId: '.slack', - secrets: {}, - isPreconfigured: true, - isDeprecated: false, - isSystemAction: false, - name: 'test', config: { foo: 'bar', }, - }, - { - id: 'system-connector-.cases', - actionTypeId: '.cases', - name: 'System action: .cases', - config: {}, - secrets: {}, + id: '1', isDeprecated: false, isMissingSecrets: false, isPreconfigured: false, - isSystemAction: true, + isSystemAction: false, + name: 'test', + referencedByCount: 6, }, - ], - connectorTokenClient: connectorTokenClientMock.create(), - getEventLogClient, + { + actionTypeId: '.slack', + id: 'testPreconfigured', + isDeprecated: false, + isPreconfigured: true, + isSystemAction: false, + name: 'test', + referencedByCount: 2, + }, + ]); + + expect(logger.warn).toHaveBeenCalledWith( + 'Error validating connector: 1, Error: [actionTypeId]: expected value of type [string] but got [undefined]' + ); }); + }); - const result = await actionsClient.getAll({ includeSystemActions: true }); + describe('getAllSystemConnectors()', () => { + describe('authorization', () => { + function getAllOperation(): ReturnType { + scopedClusterClient.asInternalUser.search.mockResponse( + // @ts-expect-error not full search response + { + aggregations: { + 'system-connector-.test': { doc_count: 2 }, + }, + } + ); - expect(result).toEqual([ - { - actionTypeId: '.cases', - id: 'system-connector-.cases', - isDeprecated: false, - isPreconfigured: false, - isSystemAction: true, - name: 'System action: .cases', - referencedByCount: 2, - }, - { - id: '1', - name: 'test', - isMissingSecrets: false, - config: { foo: 'bar' }, - isPreconfigured: false, - isDeprecated: false, - isSystemAction: false, - referencedByCount: 6, - }, - { - id: 'testPreconfigured', - actionTypeId: '.slack', - name: 'test', - isPreconfigured: true, - isSystemAction: false, - isDeprecated: false, - referencedByCount: 2, - }, - ]); - }); + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + inMemoryConnectors: [ + { + id: 'system-connector-.test', + actionTypeId: '.test', + name: 'Test system action', + config: {}, + secrets: {}, + isDeprecated: false, + isMissingSecrets: false, + isPreconfigured: false, + isSystemAction: true, + }, + ], + connectorTokenClient: connectorTokenClientMock.create(), + getEventLogClient, + }); - test('validates connectors before return', async () => { - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ - total: 1, - per_page: 10, - page: 1, - saved_objects: [ + return actionsClient.getAllSystemConnectors(); + } + + test('ensures user is authorised to get the type of action', async () => { + await getAllOperation(); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + }); + + test('throws when user is not authorised to get the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get all actions`) + ); + + await expect(getAllOperation()).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get all actions]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ operation: 'get' }); + }); + }); + + describe('auditLogger', () => { + test('logs audit event when not authorised to search connectors', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.getAllSystemConnectors()).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_find', + outcome: 'failure', + }), + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + + test('get all system actions correctly', async () => { + scopedClusterClient.asInternalUser.search.mockResponse( + // @ts-expect-error not full search response { - id: '1', - type: 'type', - attributes: { + aggregations: { + 'system-connector-.test': { doc_count: 2 }, + }, + } + ); + + actionsClient = new ActionsClient({ + logger, + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + kibanaIndices, + actionExecutor, + ephemeralExecutionEnqueuer, + bulkExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + inMemoryConnectors: [ + { + id: 'testPreconfigured', + actionTypeId: 'my-action-type', + secrets: { + test: 'test1', + }, + isPreconfigured: true, + isDeprecated: false, + isSystemAction: false, name: 'test', - isMissingSecrets: false, config: { foo: 'bar', }, }, - score: 1, - references: [], - }, - ], - }); - scopedClusterClient.asInternalUser.search.mockResponse( - // @ts-expect-error not full search response - { - aggregations: { - '1': { doc_count: 6 }, - testPreconfigured: { doc_count: 2 }, - }, - } - ); + { + id: 'system-connector-.test', + actionTypeId: '.test', + name: 'Test system action', + config: {}, + secrets: {}, + isDeprecated: false, + isMissingSecrets: false, + isPreconfigured: false, + isSystemAction: true, + }, + ], + connectorTokenClient: connectorTokenClientMock.create(), + getEventLogClient, + }); - actionsClient = new ActionsClient({ - logger, - actionTypeRegistry, - unsecuredSavedObjectsClient, - scopedClusterClient, - kibanaIndices, - actionExecutor, - ephemeralExecutionEnqueuer, - bulkExecutionEnqueuer, - request, - authorization: authorization as unknown as ActionsAuthorization, - inMemoryConnectors: [ + const result = await actionsClient.getAllSystemConnectors(); + + expect(result).toEqual([ { - id: 'testPreconfigured', - actionTypeId: '.slack', - secrets: {}, - isPreconfigured: true, + id: 'system-connector-.test', + actionTypeId: '.test', + name: 'Test system action', + isPreconfigured: false, isDeprecated: false, - isSystemAction: false, - name: 'test', - config: { - foo: 'bar', - }, + isSystemAction: true, + referencedByCount: 2, }, - ], - connectorTokenClient: connectorTokenClientMock.create(), - getEventLogClient, + ]); }); - - const result = await actionsClient.getAll({ includeSystemActions: true }); - expect(result).toEqual([ - { - config: { - foo: 'bar', - }, - id: '1', - isDeprecated: false, - isMissingSecrets: false, - isPreconfigured: false, - isSystemAction: false, - name: 'test', - referencedByCount: 6, - }, - { - actionTypeId: '.slack', - id: 'testPreconfigured', - isDeprecated: false, - isPreconfigured: true, - isSystemAction: false, - name: 'test', - referencedByCount: 2, - }, - ]); - - expect(logger.warn).toHaveBeenCalledWith( - 'Error validating connector: 1, Error: [actionTypeId]: expected value of type [string] but got [undefined]' - ); }); }); diff --git a/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.ts b/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.ts index 9c3b9c13924fd..1aecd76d40a37 100644 --- a/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.ts +++ b/x-pack/plugins/actions/server/application/connector/methods/get_all/get_all.ts @@ -127,6 +127,12 @@ async function getAllHelper({ connectors: mergedResult, }); + validateConnectors(connectors, logger); + + return connectors; +} + +const validateConnectors = (connectors: ConnectorWithExtraFindData[], logger: Logger) => { connectors.forEach((connector) => { // Try to validate the connectors, but don't throw. try { @@ -135,6 +141,48 @@ async function getAllHelper({ logger.warn(`Error validating connector: ${connector.id}, ${e}`); } }); +}; + +export async function getAllSystemConnectors({ + context, +}: { + context: GetAllParams['context']; +}): Promise { + try { + await context.authorization.ensureAuthorized({ operation: 'get' }); + } catch (error) { + context.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.FIND, + error, + }) + ); + + throw error; + } + + const systemConnectors = context.inMemoryConnectors.filter( + (connector) => connector.isSystemAction + ); + + const transformedSystemConnectors = systemConnectors + .map((systemConnector) => ({ + id: systemConnector.id, + actionTypeId: systemConnector.actionTypeId, + name: systemConnector.name, + isPreconfigured: systemConnector.isPreconfigured, + isDeprecated: isConnectorDeprecated(systemConnector), + isSystemAction: systemConnector.isSystemAction, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const connectors = await injectExtraFindData({ + kibanaIndices: context.kibanaIndices, + esClient: context.scopedClusterClient.asInternalUser, + connectors: transformedSystemConnectors, + }); + + validateConnectors(connectors, context.logger); return connectors; } diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index ac7d42d658e2b..eb74d320ba8c3 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -13,7 +13,7 @@ import { } from '@kbn/core/server/mocks'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { Logger } from '@kbn/core/server'; -import { actionsClientMock } from './actions_client/actions_client.mock'; +import { actionsClientMock, ActionsClientMock } from './actions_client/actions_client.mock'; import { PluginSetupContract, PluginStartContract, renderActionParameterTemplates } from './plugin'; import { Services, UnsecuredServices } from './types'; import { actionsAuthorizationMock } from './authorization/actions_authorization.mock'; @@ -21,6 +21,8 @@ import { ConnectorTokenClient } from './lib/connector_token_client'; import { unsecuredActionsClientMock } from './unsecured_actions_client/unsecured_actions_client.mock'; export { actionsAuthorizationMock }; export { actionsClientMock }; +export type { ActionsClientMock }; + const logger = loggingSystemMock.create().get() as jest.Mocked; const createSetupMock = () => { @@ -49,6 +51,7 @@ const createStartMock = () => { .mockReturnValue(actionsAuthorizationMock.create()), inMemoryConnectors: [], renderActionParameterTemplates: jest.fn(), + isSystemActionConnector: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 004da4cac0339..4d589699a2caa 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -896,5 +896,53 @@ describe('Actions Plugin', () => { expect(pluginSetup.getActionsHealth()).toEqual({ hasPermanentEncryptionKey: true }); }); }); + + describe('isSystemActionConnector()', () => { + it('should return true if the connector is a system connector', async () => { + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup); + + pluginSetup.registerType({ + id: '.cases', + name: 'Cases', + minimumLicenseRequired: 'platinum', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + isSystemActionType: true, + executor, + }); + + const pluginStart = await plugin.start(coreStart, pluginsStart); + expect(pluginStart.isSystemActionConnector('system-connector-.cases')).toBe(true); + }); + + it('should return false if the connector is not a system connector', async () => { + // coreMock.createSetup doesn't support Plugin generics + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pluginSetup = await plugin.setup(coreSetup as any, pluginsSetup); + + pluginSetup.registerType({ + id: '.cases', + name: 'Cases', + minimumLicenseRequired: 'platinum', + supportedFeatureIds: ['alerting'], + validate: { + config: { schema: schema.object({}) }, + secrets: { schema: schema.object({}) }, + params: { schema: schema.object({}) }, + }, + isSystemActionType: true, + executor, + }); + + const pluginStart = await plugin.start(coreStart, pluginsStart); + expect(pluginStart.isSystemActionConnector('preconfiguredServerLog')).toBe(false); + }); + }); }); }); diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 1ef28b10e6440..24253e7d01ae4 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -159,6 +159,7 @@ export interface PluginStartContract { params: Params, variables: Record ): Params; + isSystemActionConnector: (connectorId: string) => boolean; } export interface ActionsPluginsSetup { @@ -603,6 +604,12 @@ export class ActionsPlugin implements Plugin renderActionParameterTemplates(this.logger, actionTypeRegistry, ...args), + isSystemActionConnector: (connectorId: string): boolean => { + return this.inMemoryConnectors.some( + (inMemoryConnector) => + inMemoryConnector.isSystemAction && inMemoryConnector.id === connectorId + ); + }, }; } diff --git a/x-pack/plugins/actions/server/routes/connector/get_all_system/get_all_system.test.ts b/x-pack/plugins/actions/server/routes/connector/get_all_system/get_all_system.test.ts new file mode 100644 index 0000000000000..8130c8cd3a809 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/connector/get_all_system/get_all_system.test.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getAllConnectorsIncludingSystemRoute } from './get_all_system'; +import { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../lib/license_state.mock'; +import { mockHandlerArguments } from '../../legacy/_mock_handler_arguments'; +import { verifyAccessAndContext } from '../../verify_access_and_context'; +import { actionsClientMock } from '../../../actions_client/actions_client.mock'; + +jest.mock('../../verify_access_and_context', () => ({ + verifyAccessAndContext: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); + (verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler); +}); + +describe('getAllConnectorsIncludingSystemRoute', () => { + it('get all connectors with proper parameters', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getAllConnectorsIncludingSystemRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connectors"`); + + const actionsClient = actionsClientMock.create(); + actionsClient.getAll.mockResolvedValueOnce([ + { + id: '.system-action-id', + isPreconfigured: false, + isSystemAction: true, + isDeprecated: false, + name: 'my system action', + actionTypeId: '.system-action-type', + isMissingSecrets: false, + config: {}, + referencedByCount: 0, + }, + ]); + + const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Array [ + Object { + "config": Object {}, + "connector_type_id": ".system-action-type", + "id": ".system-action-id", + "is_deprecated": false, + "is_missing_secrets": false, + "is_preconfigured": false, + "is_system_action": true, + "name": "my system action", + "referenced_by_count": 0, + }, + ], + } + `); + + expect(actionsClient.getAll).toHaveBeenCalledWith({ includeSystemActions: true }); + + expect(res.ok).toHaveBeenCalledWith({ + body: [ + { + config: {}, + connector_type_id: '.system-action-type', + id: '.system-action-id', + is_deprecated: false, + is_missing_secrets: false, + is_preconfigured: false, + is_system_action: true, + name: 'my system action', + referenced_by_count: 0, + }, + ], + }); + }); + + it('ensures the license allows getting all connectors', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getAllConnectorsIncludingSystemRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connectors"`); + + const actionsClient = actionsClientMock.create(); + actionsClient.getAll.mockResolvedValueOnce([]); + + const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok']); + + await handler(context, req, res); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); + + it('ensures the license check prevents getting all connectors', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + (verifyAccessAndContext as jest.Mock).mockImplementation(() => async () => { + throw new Error('OMG'); + }); + + getAllConnectorsIncludingSystemRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connectors"`); + + const actionsClient = actionsClientMock.create(); + actionsClient.getAll.mockResolvedValueOnce([]); + + const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok']); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); +}); diff --git a/x-pack/plugins/actions/server/routes/connector/get_all_system/get_all_system.ts b/x-pack/plugins/actions/server/routes/connector/get_all_system/get_all_system.ts new file mode 100644 index 0000000000000..9ba51287c56fd --- /dev/null +++ b/x-pack/plugins/actions/server/routes/connector/get_all_system/get_all_system.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from '@kbn/core/server'; +import { AllConnectorsResponseV1 } from '../../../../common/routes/connector/response'; +import { ActionsRequestHandlerContext } from '../../../types'; +import { INTERNAL_BASE_ACTION_API_PATH } from '../../../../common'; +import { ILicenseState } from '../../../lib'; +import { verifyAccessAndContext } from '../../verify_access_and_context'; +import { transformGetAllConnectorsResponseV1 } from '../get_all/transforms'; + +export const getAllConnectorsIncludingSystemRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_BASE_ACTION_API_PATH}/connectors`, + validate: {}, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const actionsClient = (await context.actions).getActionsClient(); + const result = await actionsClient.getAll({ + includeSystemActions: true, + }); + + const responseBody: AllConnectorsResponseV1[] = transformGetAllConnectorsResponseV1(result); + return res.ok({ body: responseBody }); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/data/alerts_filter_query/constants.ts b/x-pack/plugins/actions/server/routes/connector/get_all_system/index.ts similarity index 57% rename from x-pack/plugins/alerting/server/data/alerts_filter_query/constants.ts rename to x-pack/plugins/actions/server/routes/connector/get_all_system/index.ts index bce6890c22f2c..fd5e0e8b89caa 100644 --- a/x-pack/plugins/alerting/server/data/alerts_filter_query/constants.ts +++ b/x-pack/plugins/actions/server/routes/connector/get_all_system/index.ts @@ -5,9 +5,4 @@ * 2.0. */ -export const filterStateStore = { - APP_STATE: 'appState', - GLOBAL_STATE: 'globalState', -} as const; - -export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore]; +export { getAllConnectorsIncludingSystemRoute } from './get_all_system'; diff --git a/x-pack/plugins/actions/server/routes/connector/list_types/list_types.ts b/x-pack/plugins/actions/server/routes/connector/list_types/list_types.ts index 078c51743c4d9..b53b213dcc188 100644 --- a/x-pack/plugins/actions/server/routes/connector/list_types/list_types.ts +++ b/x-pack/plugins/actions/server/routes/connector/list_types/list_types.ts @@ -35,7 +35,9 @@ export const listTypesRoute = ( // Assert versioned inputs const query: ConnectorTypesRequestQueryV1 = req.query; - const connectorTypes = await actionsClient.listTypes({ featureId: query?.feature_id }); + const connectorTypes = await actionsClient.listTypes({ + featureId: query?.feature_id, + }); const responseBody: ConnectorTypesResponseV1[] = transformListTypesResponseV1(connectorTypes); diff --git a/x-pack/plugins/actions/server/routes/connector/list_types_system/index.ts b/x-pack/plugins/actions/server/routes/connector/list_types_system/index.ts new file mode 100644 index 0000000000000..0ddc95a6732c3 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/connector/list_types_system/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { listTypesWithSystemRoute } from './list_types_system'; diff --git a/x-pack/plugins/actions/server/routes/connector/list_types_system/list_types_system.test.ts b/x-pack/plugins/actions/server/routes/connector/list_types_system/list_types_system.test.ts new file mode 100644 index 0000000000000..af6e057cafc9c --- /dev/null +++ b/x-pack/plugins/actions/server/routes/connector/list_types_system/list_types_system.test.ts @@ -0,0 +1,249 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '@kbn/core/server/mocks'; +import { LicenseType } from '@kbn/licensing-plugin/server'; +import { licenseStateMock } from '../../../lib/license_state.mock'; +import { mockHandlerArguments } from '../../legacy/_mock_handler_arguments'; +import { listTypesWithSystemRoute } from './list_types_system'; +import { verifyAccessAndContext } from '../../verify_access_and_context'; +import { actionsClientMock } from '../../../mocks'; + +jest.mock('../../verify_access_and_context', () => ({ + verifyAccessAndContext: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); + (verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler); +}); + +describe('listTypesWithSystemRoute', () => { + it('lists action types with proper parameters', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + listTypesWithSystemRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector_types"`); + + const listTypes = [ + { + id: '1', + name: 'name', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'gold' as LicenseType, + supportedFeatureIds: ['alerting'], + isSystemActionType: true, + }, + ]; + + const actionsClient = actionsClientMock.create(); + actionsClient.listTypes.mockResolvedValueOnce(listTypes); + const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok']); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Array [ + Object { + "enabled": true, + "enabled_in_config": true, + "enabled_in_license": true, + "id": "1", + "is_system_action_type": true, + "minimum_license_required": "gold", + "name": "name", + "supported_feature_ids": Array [ + "alerting", + ], + }, + ], + } + `); + + expect(res.ok).toHaveBeenCalledWith({ + body: [ + { + id: '1', + name: 'name', + enabled: true, + enabled_in_config: true, + enabled_in_license: true, + supported_feature_ids: ['alerting'], + minimum_license_required: 'gold', + is_system_action_type: true, + }, + ], + }); + }); + + it('passes feature_id if provided as query parameter', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + listTypesWithSystemRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector_types"`); + + const listTypes = [ + { + id: '1', + name: 'name', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + supportedFeatureIds: ['alerting'], + minimumLicenseRequired: 'gold' as LicenseType, + isSystemActionType: false, + }, + ]; + + const actionsClient = actionsClientMock.create(); + actionsClient.listTypes.mockResolvedValueOnce(listTypes); + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { + query: { + feature_id: 'alerting', + }, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Array [ + Object { + "enabled": true, + "enabled_in_config": true, + "enabled_in_license": true, + "id": "1", + "is_system_action_type": false, + "minimum_license_required": "gold", + "name": "name", + "supported_feature_ids": Array [ + "alerting", + ], + }, + ], + } + `); + + expect(actionsClient.listTypes).toHaveBeenCalledTimes(1); + expect(actionsClient.listTypes.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "featureId": "alerting", + "includeSystemActionTypes": true, + }, + ] + `); + + expect(res.ok).toHaveBeenCalledWith({ + body: [ + { + id: '1', + name: 'name', + enabled: true, + enabled_in_config: true, + enabled_in_license: true, + supported_feature_ids: ['alerting'], + minimum_license_required: 'gold', + is_system_action_type: false, + }, + ], + }); + }); + + it('ensures the license allows listing action types', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + listTypesWithSystemRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector_types"`); + + const listTypes = [ + { + id: '1', + name: 'name', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + supportedFeatureIds: ['alerting'], + minimumLicenseRequired: 'gold' as LicenseType, + isSystemActionType: false, + }, + ]; + + const actionsClient = actionsClientMock.create(); + actionsClient.listTypes.mockResolvedValueOnce(listTypes); + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); + + it('ensures the license check prevents listing action types', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + (verifyAccessAndContext as jest.Mock).mockImplementation(() => async () => { + throw new Error('OMG'); + }); + + listTypesWithSystemRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector_types"`); + + const listTypes = [ + { + id: '1', + name: 'name', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + supportedFeatureIds: ['alerting'], + minimumLicenseRequired: 'gold' as LicenseType, + isSystemActionType: false, + }, + ]; + + const actionsClient = actionsClientMock.create(); + actionsClient.listTypes.mockResolvedValueOnce(listTypes); + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); +}); diff --git a/x-pack/plugins/actions/server/routes/connector/list_types_system/list_types_system.ts b/x-pack/plugins/actions/server/routes/connector/list_types_system/list_types_system.ts new file mode 100644 index 0000000000000..6611830f6a3c7 --- /dev/null +++ b/x-pack/plugins/actions/server/routes/connector/list_types_system/list_types_system.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IRouter } from '@kbn/core/server'; +import { ConnectorTypesResponseV1 } from '../../../../common/routes/connector/response'; +import { + connectorTypesQuerySchemaV1, + ConnectorTypesRequestQueryV1, +} from '../../../../common/routes/connector/apis/connector_types'; +import { ActionsRequestHandlerContext } from '../../../types'; +import { INTERNAL_BASE_ACTION_API_PATH } from '../../../../common'; +import { ILicenseState } from '../../../lib'; +import { verifyAccessAndContext } from '../../verify_access_and_context'; +import { transformListTypesResponseV1 } from '../list_types/transforms'; + +export const listTypesWithSystemRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_BASE_ACTION_API_PATH}/connector_types`, + validate: { + query: connectorTypesQuerySchemaV1, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const actionsClient = (await context.actions).getActionsClient(); + + // Assert versioned inputs + const query: ConnectorTypesRequestQueryV1 = req.query; + + const connectorTypes = await actionsClient.listTypes({ + featureId: query?.feature_id, + includeSystemActionTypes: true, + }); + + const responseBody: ConnectorTypesResponseV1[] = + transformListTypesResponseV1(connectorTypes); + + return res.ok({ body: responseBody }); + }) + ) + ); +}; diff --git a/x-pack/plugins/actions/server/routes/execute.test.ts b/x-pack/plugins/actions/server/routes/execute.test.ts index 12960aeae47e6..3bcd2cf60811d 100644 --- a/x-pack/plugins/actions/server/routes/execute.test.ts +++ b/x-pack/plugins/actions/server/routes/execute.test.ts @@ -24,6 +24,10 @@ beforeEach(() => { }); describe('executeActionRoute', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('executes an action with proper parameters', async () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); @@ -167,4 +171,35 @@ describe('executeActionRoute', () => { expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); }); + + it('returns a bad request for system connectors', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockReturnValue(true); + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { + body: { + params: {}, + }, + params: { + id: 'system-connector-.test-connector', + }, + }, + ['ok'] + ); + + executeActionRoute(router, licenseState); + + const [_, handler] = router.post.mock.calls[0]; + + await handler(context, req, res); + + expect(actionsClient.execute).not.toHaveBeenCalled(); + expect(res.ok).not.toHaveBeenCalled(); + expect(res.badRequest).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/actions/server/routes/execute.ts b/x-pack/plugins/actions/server/routes/execute.ts index fff947155bec3..406b9983b7bda 100644 --- a/x-pack/plugins/actions/server/routes/execute.ts +++ b/x-pack/plugins/actions/server/routes/execute.ts @@ -49,12 +49,18 @@ export const executeActionRoute = ( const actionsClient = (await context.actions).getActionsClient(); const { params } = req.body; const { id } = req.params; + + if (actionsClient.isSystemAction(id)) { + return res.badRequest({ body: 'Execution of system action is not allowed' }); + } + const body: ActionTypeExecutorResult = await actionsClient.execute({ params, actionId: id, source: asHttpRequestExecutionSource(req), relatedSavedObjects: [], }); + return body ? res.ok({ body: rewriteBodyRes(body), diff --git a/x-pack/plugins/actions/server/routes/index.ts b/x-pack/plugins/actions/server/routes/index.ts index ad28c55959039..ca25b88bcf798 100644 --- a/x-pack/plugins/actions/server/routes/index.ts +++ b/x-pack/plugins/actions/server/routes/index.ts @@ -8,7 +8,9 @@ import { IRouter } from '@kbn/core/server'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import { getAllConnectorsRoute } from './connector/get_all'; +import { getAllConnectorsIncludingSystemRoute } from './connector/get_all_system'; import { listTypesRoute } from './connector/list_types'; +import { listTypesWithSystemRoute } from './connector/list_types_system'; import { ILicenseState } from '../lib'; import { ActionsRequestHandlerContext } from '../types'; import { createActionRoute } from './create'; @@ -45,4 +47,6 @@ export function defineRoutes(opts: RouteOptions) { getGlobalExecutionKPIRoute(router, licenseState); getOAuthAccessToken(router, licenseState, actionsConfigUtils); + getAllConnectorsIncludingSystemRoute(router, licenseState); + listTypesWithSystemRoute(router, licenseState); } diff --git a/x-pack/plugins/alerting/common/maintenance_window.ts b/x-pack/plugins/alerting/common/maintenance_window.ts index 8b7646670cc76..80cdb88858643 100644 --- a/x-pack/plugins/alerting/common/maintenance_window.ts +++ b/x-pack/plugins/alerting/common/maintenance_window.ts @@ -5,6 +5,7 @@ * 2.0. */ import { Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import { FilterStateStore } from '@kbn/es-query'; import { RRuleParams } from './rrule_type'; export enum MaintenanceWindowStatus { @@ -13,14 +14,6 @@ export enum MaintenanceWindowStatus { Finished = 'finished', Archived = 'archived', } - -export const filterStateStore = { - APP_STATE: 'appState', - GLOBAL_STATE: 'globalState', -} as const; - -export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore]; - export interface MaintenanceWindowModificationMetadata { createdBy: string | null; updatedBy: string | null; diff --git a/x-pack/plugins/alerting/common/routes/alerts_filter_query/index.ts b/x-pack/plugins/alerting/common/routes/alerts_filter_query/index.ts index 093299dbe66f2..d5a5f1f1f5c57 100644 --- a/x-pack/plugins/alerting/common/routes/alerts_filter_query/index.ts +++ b/x-pack/plugins/alerting/common/routes/alerts_filter_query/index.ts @@ -5,10 +5,5 @@ * 2.0. */ -export { filterStateStore } from './constants/latest'; -export type { FilterStateStore } from './constants/latest'; export { alertsFilterQuerySchema } from './schemas/latest'; - -export { filterStateStore as filterStateStoreV1 } from './constants/v1'; -export type { FilterStateStore as FilterStateStoreV1 } from './constants/v1'; export { alertsFilterQuerySchema as alertsFilterQuerySchemaV1 } from './schemas/v1'; diff --git a/x-pack/plugins/alerting/common/routes/alerts_filter_query/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/alerts_filter_query/schemas/v1.ts index 08614efb96b70..26249eb3a53f2 100644 --- a/x-pack/plugins/alerting/common/routes/alerts_filter_query/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/alerts_filter_query/schemas/v1.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { filterStateStore } from '..'; +import { FilterStateStore } from '@kbn/es-query'; export const alertsFilterQuerySchema = schema.object({ kql: schema.string(), @@ -17,8 +17,8 @@ export const alertsFilterQuerySchema = schema.object({ $state: schema.maybe( schema.object({ store: schema.oneOf([ - schema.literal(filterStateStore.APP_STATE), - schema.literal(filterStateStore.GLOBAL_STATE), + schema.literal(FilterStateStore.APP_STATE), + schema.literal(FilterStateStore.GLOBAL_STATE), ]), }) ), diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_edit/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_edit/schemas/v1.ts index 3a17eaee30974..2d05836a3c30e 100644 --- a/x-pack/plugins/alerting/common/routes/rule/apis/bulk_edit/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/apis/bulk_edit/schemas/v1.ts @@ -20,7 +20,7 @@ export const ruleSnoozeScheduleSchema = schema.object({ }); const ruleActionSchema = schema.object({ - group: schema.string(), + group: schema.maybe(schema.string()), id: schema.string(), params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), uuid: schema.maybe(schema.string()), diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts index 7d1a37b7c75f7..f24280eebd44c 100644 --- a/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts @@ -46,7 +46,7 @@ export const actionAlertsFilterSchema = schema.object({ export const actionSchema = schema.object({ uuid: schema.maybe(schema.string()), - group: schema.string(), + group: schema.maybe(schema.string()), id: schema.string(), actionTypeId: schema.maybe(schema.string()), params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }), diff --git a/x-pack/plugins/alerting/common/routes/rule/common/constants/v1.ts b/x-pack/plugins/alerting/common/routes/rule/common/constants/v1.ts index 3bf7208efe37f..fbeb08ba6bc7f 100644 --- a/x-pack/plugins/alerting/common/routes/rule/common/constants/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/common/constants/v1.ts @@ -43,11 +43,6 @@ export const ruleExecutionStatusWarningReason = { MAX_QUEUED_ACTIONS: 'maxQueuedActions', } as const; -export const filterStateStore = { - APP_STATE: 'appState', - GLOBAL_STATE: 'globalState', -} as const; - export type RuleNotifyWhen = typeof ruleNotifyWhen[keyof typeof ruleNotifyWhen]; export type RuleLastRunOutcomeValues = typeof ruleLastRunOutcomeValues[keyof typeof ruleLastRunOutcomeValues]; @@ -57,4 +52,3 @@ export type RuleExecutionStatusErrorReason = typeof ruleExecutionStatusErrorReason[keyof typeof ruleExecutionStatusErrorReason]; export type RuleExecutionStatusWarningReason = typeof ruleExecutionStatusWarningReason[keyof typeof ruleExecutionStatusWarningReason]; -export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore]; diff --git a/x-pack/plugins/alerting/common/routes/rule/common/index.ts b/x-pack/plugins/alerting/common/routes/rule/common/index.ts index 669bfc484070a..5989a3a993e7a 100644 --- a/x-pack/plugins/alerting/common/routes/rule/common/index.ts +++ b/x-pack/plugins/alerting/common/routes/rule/common/index.ts @@ -11,7 +11,6 @@ export { ruleExecutionStatusValues, ruleExecutionStatusErrorReason, ruleExecutionStatusWarningReason, - filterStateStore, } from './constants/latest'; export type { @@ -20,7 +19,6 @@ export type { RuleExecutionStatusValues, RuleExecutionStatusErrorReason, RuleExecutionStatusWarningReason, - FilterStateStore, } from './constants/latest'; export { @@ -29,7 +27,6 @@ export { ruleExecutionStatusValues as ruleExecutionStatusValuesV1, ruleExecutionStatusErrorReason as ruleExecutionStatusErrorReasonV1, ruleExecutionStatusWarningReason as ruleExecutionStatusWarningReasonV1, - filterStateStore as filterStateStoreV1, } from './constants/v1'; export type { @@ -38,5 +35,4 @@ export type { RuleExecutionStatusValues as RuleExecutionStatusValuesV1, RuleExecutionStatusErrorReason as RuleExecutionStatusErrorReasonV1, RuleExecutionStatusWarningReason as RuleExecutionStatusWarningReasonV1, - FilterStateStore as FilterStateStoreV1, } from './constants/v1'; diff --git a/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts index f485aa7374d6c..2b6f09ef0dfd8 100644 --- a/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts @@ -66,12 +66,13 @@ const actionAlertsFilterSchema = schema.object({ const actionSchema = schema.object({ uuid: schema.maybe(schema.string()), - group: schema.string(), + group: schema.maybe(schema.string()), id: schema.string(), connector_type_id: schema.string(), params: actionParamsSchema, frequency: schema.maybe(actionFrequencySchema), alerts_filter: schema.maybe(actionAlertsFilterSchema), + use_alert_data_for_template: schema.maybe(schema.boolean()), }); export const ruleExecutionStatusSchema = schema.object({ diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index 6a66b39720402..bc6c60fd75a53 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -122,6 +122,16 @@ export interface RuleAction { useAlertDataForTemplate?: boolean; } +export interface RuleSystemAction { + uuid?: string; + id: string; + actionTypeId: string; + params: RuleActionParams; +} + +export type RuleActionKey = keyof RuleAction; +export type RuleSystemActionKey = keyof RuleSystemAction; + export interface RuleLastRun { outcome: RuleLastRunOutcomes; outcomeOrder?: number; @@ -155,6 +165,7 @@ export interface Rule { consumer: string; schedule: IntervalSchedule; actions: RuleAction[]; + systemActions?: RuleSystemAction[]; params: Params; mapped_params?: MappedParams; scheduledTaskId?: string | null; diff --git a/x-pack/plugins/alerting/server/application/alerts_filter_query/schemas/alerts_filter_query_schemas.ts b/x-pack/plugins/alerting/server/application/alerts_filter_query/schemas/alerts_filter_query_schemas.ts index bf5a24b6e4399..26249eb3a53f2 100644 --- a/x-pack/plugins/alerting/server/application/alerts_filter_query/schemas/alerts_filter_query_schemas.ts +++ b/x-pack/plugins/alerting/server/application/alerts_filter_query/schemas/alerts_filter_query_schemas.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { filterStateStore } from '../constants'; +import { FilterStateStore } from '@kbn/es-query'; export const alertsFilterQuerySchema = schema.object({ kql: schema.string(), @@ -17,8 +17,8 @@ export const alertsFilterQuerySchema = schema.object({ $state: schema.maybe( schema.object({ store: schema.oneOf([ - schema.literal(filterStateStore.APP_STATE), - schema.literal(filterStateStore.GLOBAL_STATE), + schema.literal(FilterStateStore.APP_STATE), + schema.literal(FilterStateStore.GLOBAL_STATE), ]), }) ), diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/constants.ts b/x-pack/plugins/alerting/server/application/maintenance_window/constants.ts index 4b5920d645584..203ce8ec3396a 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/constants.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/constants.ts @@ -17,8 +17,3 @@ export const maintenanceWindowCategoryIdTypes = { SECURITY_SOLUTION: 'securitySolution', MANAGEMENT: 'management', } as const; - -export const filterStateStore = { - APP_STATE: 'appState', - GLOBAL_STATE: 'globalState', -} as const; diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/create/create_maintenance_window.test.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/create/create_maintenance_window.test.ts index ca98ba2ea72d1..efe8a9a9477d5 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/create/create_maintenance_window.test.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/create/create_maintenance_window.test.ts @@ -16,6 +16,7 @@ import { } from '../../../../../common'; import { getMockMaintenanceWindow } from '../../../../data/maintenance_window/test_helpers'; import type { MaintenanceWindow } from '../../types'; +import { FilterStateStore } from '@kbn/es-query'; const savedObjectsClient = savedObjectsClientMock.create(); @@ -168,7 +169,7 @@ describe('MaintenanceWindowClient - create', () => { type: 'phrase', }, $state: { - store: 'appState', + store: FilterStateStore.APP_STATE, }, query: { match_phrase: { @@ -281,7 +282,7 @@ describe('MaintenanceWindowClient - create', () => { type: 'phrase', }, $state: { - store: 'appState', + store: FilterStateStore.APP_STATE, }, query: { match_phrase: { diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/update/update_maintenance_window.test.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/update/update_maintenance_window.test.ts index 08a01556bede8..8d25930aa0784 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/update/update_maintenance_window.test.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/update/update_maintenance_window.test.ts @@ -17,6 +17,7 @@ import { } from '../../../../../common'; import { getMockMaintenanceWindow } from '../../../../data/maintenance_window/test_helpers'; import type { MaintenanceWindow } from '../../types'; +import { FilterStateStore } from '@kbn/es-query'; const savedObjectsClient = savedObjectsClientMock.create(); @@ -260,7 +261,7 @@ describe('MaintenanceWindowClient - update', () => { type: 'phrase', }, $state: { - store: 'appState', + store: FilterStateStore.APP_STATE, }, query: { match_phrase: { @@ -407,7 +408,7 @@ describe('MaintenanceWindowClient - update', () => { type: 'phrase', }, $state: { - store: 'appState', + store: FilterStateStore.APP_STATE, }, query: { match_phrase: { diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/transforms/transform_maintenance_window_to_maintenance_window_attributes.ts b/x-pack/plugins/alerting/server/application/maintenance_window/transforms/transform_maintenance_window_to_maintenance_window_attributes.ts index 9d26443bdc07d..b6c0e22d04bc4 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/transforms/transform_maintenance_window_to_maintenance_window_attributes.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/transforms/transform_maintenance_window_to_maintenance_window_attributes.ts @@ -26,7 +26,15 @@ export const transformMaintenanceWindowToMaintenanceWindowAttributes = ( ? { categoryIds: maintenanceWindow.categoryIds } : {}), ...(maintenanceWindow.scopedQuery !== undefined - ? { scopedQuery: maintenanceWindow.scopedQuery } + ? maintenanceWindow?.scopedQuery == null + ? { scopedQuery: maintenanceWindow?.scopedQuery } + : { + scopedQuery: { + filters: maintenanceWindow?.scopedQuery?.filters ?? [], + kql: maintenanceWindow?.scopedQuery?.kql ?? '', + dsl: maintenanceWindow?.scopedQuery?.dsl ?? '', + }, + } : {}), }; }; diff --git a/x-pack/plugins/alerting/server/application/rule/constants.ts b/x-pack/plugins/alerting/server/application/rule/constants.ts index 0881868d9db8f..bc75d91375ecb 100644 --- a/x-pack/plugins/alerting/server/application/rule/constants.ts +++ b/x-pack/plugins/alerting/server/application/rule/constants.ts @@ -42,8 +42,3 @@ export const ruleExecutionStatusWarningReason = { MAX_ALERTS: 'maxAlerts', MAX_QUEUED_ACTIONS: 'maxQueuedActions', } as const; - -export const filterStateStore = { - APP_STATE: 'appState', - GLOBAL_STATE: 'globalState', -} as const; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/aggregate/aggregate_rules.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/aggregate/aggregate_rules.test.ts index 81c4dc8562bf3..858bc6ded1cb3 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/aggregate/aggregate_rules.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/aggregate/aggregate_rules.test.ts @@ -27,6 +27,7 @@ import { fromKueryExpression, nodeTypes } from '@kbn/es-query'; import { RecoveredActionGroup } from '../../../../../common'; import { DefaultRuleAggregationResult } from '../../../../routes/rule/apis/aggregate/types'; import { defaultRuleAggregationFactory } from '.'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; const taskManager = taskManagerMock.createStart(); @@ -58,11 +59,13 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, maxScheduledPerMinute: 1000, internalSavedObjectsRepository, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts index 6fec4e8a636da..6ccd32879a171 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts @@ -18,6 +18,7 @@ import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { loggerMock } from '@kbn/logging-mocks'; +import { ActionsClient } from '@kbn/actions-plugin/server'; import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; import { RecoveredActionGroup } from '../../../../../common'; @@ -28,12 +29,17 @@ import { enabledRuleForBulkOps1, enabledRuleForBulkOps2, enabledRuleForBulkOps3, - returnedRuleForBulkDelete1, - returnedRuleForBulkDelete2, - returnedRuleForBulkDelete3, + returnedRuleForBulkOps1, + returnedRuleForBulkOps2, + returnedRuleForBulkOps3, siemRuleForBulkOps1, + enabledRuleForBulkOpsWithActions1, + enabledRuleForBulkOpsWithActions2, + returnedRuleForBulkEnableWithActions1, + returnedRuleForBulkEnableWithActions2, } from '../../../../rules_client/tests/test_helpers'; import { migrateLegacyActions } from '../../../../rules_client/lib'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => { @@ -84,6 +90,8 @@ const rulesClientParams: jest.Mocked = { minimumScheduleInterval: { value: '1m', enforce: false }, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), @@ -109,11 +117,12 @@ setGlobalDate(); describe('bulkDelete', () => { let rulesClient: RulesClient; + let actionsClient: jest.Mocked; const mockCreatePointInTimeFinderAsInternalUser = ( response = { saved_objects: [enabledRuleForBulkOps1, enabledRuleForBulkOps2, enabledRuleForBulkOps3], - } + } as unknown ) => { encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest .fn() @@ -165,6 +174,48 @@ describe('bulkDelete', () => { }, validLegacyConsumers: [], }); + + actionsClient = (await rulesClientParams.getActionsClient()) as jest.Mocked; + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action:id'); + rulesClientParams.getActionsClient.mockResolvedValue(actionsClient); + }); + + test('should successfully delete two rule and return right actions', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [enabledRuleForBulkOpsWithActions1, enabledRuleForBulkOpsWithActions2], + }); + unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({ + statuses: [ + { id: 'id1', type: 'alert', success: true }, + { id: 'id2', type: 'alert', success: true }, + ], + }); + + const result = await rulesClient.bulkDeleteRules({ filter: 'fake_filter' }); + + expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledWith( + [enabledRuleForBulkOps1, enabledRuleForBulkOps2].map(({ id }) => ({ + id, + type: 'alert', + })), + undefined + ); + + expect(taskManager.bulkRemove).toHaveBeenCalledTimes(1); + expect(taskManager.bulkRemove).toHaveBeenCalledWith(['id1', 'id2']); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw==', 'MzIxOmFiYw=='] }, + expect.anything(), + expect.anything() + ); + expect(result).toStrictEqual({ + rules: [returnedRuleForBulkEnableWithActions1, returnedRuleForBulkEnableWithActions2], + errors: [], + total: 2, + taskIdsFailedToBeDeleted: [], + }); }); test('should try to delete rules, two successful and one with 500 error', async () => { @@ -196,7 +247,7 @@ describe('bulkDelete', () => { expect.anything() ); expect(result).toStrictEqual({ - rules: [returnedRuleForBulkDelete1, returnedRuleForBulkDelete3], + rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps3], errors: [{ message: 'UPS', rule: { id: 'id2', name: 'fakeName' }, status: 500 }], total: 2, taskIdsFailedToBeDeleted: [], @@ -260,7 +311,7 @@ describe('bulkDelete', () => { expect.anything() ); expect(result).toStrictEqual({ - rules: [returnedRuleForBulkDelete1], + rules: [returnedRuleForBulkOps1], errors: [{ message: 'UPS', rule: { id: 'id2', name: 'fakeName' }, status: 409 }], total: 2, taskIdsFailedToBeDeleted: [], @@ -318,7 +369,7 @@ describe('bulkDelete', () => { expect.anything() ); expect(result).toStrictEqual({ - rules: [returnedRuleForBulkDelete1, returnedRuleForBulkDelete2], + rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps2], errors: [], total: 2, taskIdsFailedToBeDeleted: [], diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts index 429afed34926c..81f0b3b0f9e58 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.ts @@ -50,6 +50,7 @@ export const bulkDeleteRules = async ( } const { ids, filter } = options; + const actionsClient = await context.getActionsClient(); const kueryNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : buildKueryNodeFilter(filter); const authorizationFilter = await getAuthorizationFilter(context, { action: 'DELETE' }); @@ -96,13 +97,17 @@ export const bulkDeleteRules = async ( // fix the type cast from SavedObjectsBulkUpdateObject to SavedObjectsBulkUpdateObject // when we are doing the bulk delete and this should fix itself const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId!); - const ruleDomain = transformRuleAttributesToRuleDomain(attributes as RuleAttributes, { - id, - logger: context.logger, - ruleType, - references, - omitGeneratedValues: false, - }); + const ruleDomain = transformRuleAttributesToRuleDomain( + attributes as RuleAttributes, + { + id, + logger: context.logger, + ruleType, + references, + omitGeneratedValues: false, + }, + (connectorId: string) => actionsClient.isSystemAction(connectorId) + ); try { ruleDomainSchema.validate(ruleDomain); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts index 3f43d6077eb35..be64257c86f8c 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts @@ -31,14 +31,20 @@ import { savedObjectWith500Error, disabledRuleForBulkDisable1, disabledRuleForBulkDisable2, + returnedRuleForBulkDisableWithActions1, + returnedRuleForBulkDisableWithActions2, enabledRuleForBulkOps1, enabledRuleForBulkOps2, + disabledRuleForBulkOpsWithActions1, + disabledRuleForBulkOpsWithActions2, returnedRuleForBulkDisable1, returnedRuleForBulkDisable2, siemRuleForBulkOps1, siemRuleForBulkOps2, } from '../../../../rules_client/tests/test_helpers'; import { migrateLegacyActions } from '../../../../rules_client/lib'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { ActionsClient } from '@kbn/actions-plugin/server'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => { @@ -96,6 +102,8 @@ const rulesClientParams: jest.Mocked = { minimumScheduleInterval: { value: '1m', enforce: false }, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), @@ -110,6 +118,8 @@ setGlobalDate(); describe('bulkDisableRules', () => { let rulesClient: RulesClient; + let actionsClient: jest.Mocked; + const mockCreatePointInTimeFinderAsInternalUser = ( response = { saved_objects: [enabledRule1, enabledRule2] } ) => { @@ -145,6 +155,9 @@ describe('bulkDisableRules', () => { beforeEach(async () => { rulesClient = new RulesClient(rulesClientParams); + actionsClient = (await rulesClientParams.getActionsClient()) as jest.Mocked; + rulesClientParams.getActionsClient.mockResolvedValue(actionsClient); + authorization.getFindAuthorizationFilter.mockResolvedValue({ ensureRuleTypeIsAuthorized() {}, }); @@ -155,6 +168,7 @@ describe('bulkDisableRules', () => { resultedActions: [], resultedReferences: [], }); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action:id'); }); test('should disable two rule', async () => { @@ -190,6 +204,38 @@ describe('bulkDisableRules', () => { }); }); + test('should disable two rule and return right actions', async () => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [disabledRuleForBulkOpsWithActions1, disabledRuleForBulkOpsWithActions2], + }); + + const result = await rulesClient.bulkDisableRules({ filter: 'fake_filter' }); + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'id1', + attributes: expect.objectContaining({ + enabled: false, + }), + }), + expect.objectContaining({ + id: 'id2', + attributes: expect.objectContaining({ + enabled: false, + }), + }), + ]), + { overwrite: true } + ); + + expect(result).toStrictEqual({ + errors: [], + rules: [returnedRuleForBulkDisableWithActions1, returnedRuleForBulkDisableWithActions2], + total: 2, + }); + }); + test('should call untrack alert if untrack is true', async () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: [disabledRuleForBulkDisable1, disabledRuleForBulkDisable2], @@ -683,25 +729,25 @@ describe('bulkDisableRules', () => { await rulesClient.bulkDisableRules({ filter: 'fake_filter' }); expect(migrateLegacyActions).toHaveBeenCalledTimes(4); - expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), { + expect(migrateLegacyActions).toHaveBeenNthCalledWith(1, expect.any(Object), { attributes: enabledRuleForBulkOps1.attributes, ruleId: enabledRuleForBulkOps1.id, actions: [], references: [], }); - expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), { + expect(migrateLegacyActions).toHaveBeenNthCalledWith(2, expect.any(Object), { attributes: enabledRuleForBulkOps2.attributes, ruleId: enabledRuleForBulkOps2.id, actions: [], references: [], }); - expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), { + expect(migrateLegacyActions).toHaveBeenNthCalledWith(3, expect.any(Object), { attributes: expect.objectContaining({ consumer: AlertConsumers.SIEM }), ruleId: siemRuleForBulkOps1.id, actions: [], references: [], }); - expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), { + expect(migrateLegacyActions).toHaveBeenNthCalledWith(4, expect.any(Object), { attributes: expect.objectContaining({ consumer: AlertConsumers.SIEM }), ruleId: siemRuleForBulkOps2.id, actions: [], diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.ts index 84229c4dc665e..72a06868e9a3d 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.ts @@ -51,6 +51,7 @@ export const bulkDisableRules = async ( } const { ids, filter, untrack = false } = options; + const actionsClient = await context.getActionsClient(); const kueryNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : buildKueryNodeFilter(filter); const authorizationFilter = await getAuthorizationFilter(context, { action: 'DISABLE' }); @@ -94,13 +95,17 @@ export const bulkDisableRules = async ( // fix the type cast from SavedObjectsBulkUpdateObject to SavedObjectsBulkUpdateObject // when we are doing the bulk disable and this should fix itself const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId!); - const ruleDomain = transformRuleAttributesToRuleDomain(attributes as RuleAttributes, { - id, - logger: context.logger, - ruleType, - references, - omitGeneratedValues: false, - }); + const ruleDomain = transformRuleAttributesToRuleDomain( + attributes as RuleAttributes, + { + id, + logger: context.logger, + ruleType, + references, + omitGeneratedValues: false, + }, + (connectorId: string) => actionsClient.isSystemAction(connectorId) + ); try { ruleDomainSchema.validate(ruleDomain); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts index 7faa85b3ef0cd..bc8ca1606e43e 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts @@ -27,7 +27,6 @@ import { ActionsAuthorization, ActionsClient } from '@kbn/actions-plugin/server' import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from '../../../../rules_client/tests/lib'; import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; -import { NormalizedAlertAction } from '../../../../rules_client/types'; import { enabledRule1, enabledRule2, @@ -36,6 +35,11 @@ import { } from '../../../../rules_client/tests/test_helpers'; import { migrateLegacyActions } from '../../../../rules_client/lib'; import { migrateLegacyActionsMock } from '../../../../rules_client/lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { ConnectorAdapter } from '../../../../connector_adapters/types'; +import { RuleAttributes } from '../../../../data/rule/types'; +import { SavedObject } from '@kbn/core/server'; +import { bulkEditOperationsSchema } from './schemas'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => { @@ -104,6 +108,8 @@ const rulesClientParams: jest.Mocked = { minimumScheduleInterval: { value: '1m', enforce: false }, isAuthenticationTypeAPIKey: isAuthenticationTypeApiKeyMock, getAuthenticationAPIKey: getAuthenticationApiKeyMock, + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), @@ -245,14 +251,17 @@ describe('bulkEdit()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', validate: { params: { validate: (params) => params }, }, - validLegacyConsumers: [], }); (migrateLegacyActions as jest.Mock).mockResolvedValue(migrateLegacyActionsMock); + + rulesClientParams.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); }); describe('tags operations', () => { @@ -537,6 +546,14 @@ describe('bulkEdit()', () => { }); describe('actions operations', () => { + const connectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test', + ruleActionParamsSchema: schema.object({ foo: schema.string() }), + buildActionParams: jest.fn(), + }; + + rulesClientParams.connectorAdapterRegistry.register(connectorAdapter); + beforeEach(() => { mockCreatePointInTimeFinderAsInternalUser({ saved_objects: [existingDecryptedRule], @@ -546,7 +563,7 @@ describe('bulkEdit()', () => { test('should add uuid to new actions', async () => { const existingAction = { frequency: { - notifyWhen: 'onActiveAlert', + notifyWhen: 'onActiveAlert' as const, summary: false, throttle: null, }, @@ -555,9 +572,10 @@ describe('bulkEdit()', () => { params: {}, uuid: '111', }; + const newAction = { frequency: { - notifyWhen: 'onActiveAlert', + notifyWhen: 'onActiveAlert' as const, summary: false, throttle: null, }, @@ -565,9 +583,10 @@ describe('bulkEdit()', () => { id: '2', params: {}, }; + const newAction2 = { frequency: { - notifyWhen: 'onActiveAlert', + notifyWhen: 'onActiveAlert' as const, summary: false, throttle: null, }, @@ -586,10 +605,12 @@ describe('bulkEdit()', () => { { ...existingAction, actionRef: 'action_0', + actionTypeId: 'test-0', }, { ...newAction, actionRef: 'action_1', + actionTypeId: 'test-1', uuid: '222', }, ], @@ -616,7 +637,7 @@ describe('bulkEdit()', () => { { field: 'actions', operation: 'add', - value: [existingAction, newAction, newAction2] as NormalizedAlertAction[], + value: [existingAction, newAction, newAction2], }, ], }); @@ -678,7 +699,11 @@ describe('bulkEdit()', () => { ...existingRule.attributes.executionStatus, lastExecutionDate: new Date(existingRule.attributes.executionStatus.lastExecutionDate), }, - actions: [existingAction, { ...newAction, uuid: '222' }], + actions: [ + { ...existingAction, actionTypeId: 'test-0' }, + { ...newAction, uuid: '222', actionTypeId: 'test-1' }, + ], + systemActions: [], id: existingRule.id, snoozeSchedule: [], }); @@ -743,7 +768,6 @@ describe('bulkEdit()', () => { async executor() { return { state: {} }; }, - category: 'test', producer: 'alerts', validate: { params: { validate: (params) => params }, @@ -753,11 +777,13 @@ describe('bulkEdit()', () => { mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, shouldWrite: true, }, + category: 'test', validLegacyConsumers: [], }); + const existingAction = { frequency: { - notifyWhen: 'onActiveAlert', + notifyWhen: 'onActiveAlert' as const, summary: false, throttle: null, }, @@ -780,7 +806,7 @@ describe('bulkEdit()', () => { }; const newAction = { frequency: { - notifyWhen: 'onActiveAlert', + notifyWhen: 'onActiveAlert' as const, summary: false, throttle: null, }, @@ -834,7 +860,7 @@ describe('bulkEdit()', () => { { field: 'actions', operation: 'add', - value: [existingAction, newAction] as NormalizedAlertAction[], + value: [existingAction, newAction], }, ], }); @@ -910,8 +936,651 @@ describe('bulkEdit()', () => { ], id: existingRule.id, snoozeSchedule: [], + systemActions: [], + }); + }); + + test('should add system and default actions', async () => { + const defaultAction = { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + id: '1', + params: {}, + }; + + const systemAction = { + id: 'system_action-id', + params: {}, + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + ...existingRule, + attributes: { + ...existingRule.attributes, + actions: [ + { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + params: {}, + actionRef: 'action_0', + actionTypeId: 'test-1', + uuid: '222', + }, + { + params: {}, + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + uuid: '222', + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + ], + }); + + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test-1', + config: {}, + isMissingSecrets: false, + name: 'test default connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [defaultAction, systemAction], + }, + ], + }); + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith( + [ + { + ...existingRule, + attributes: { + ...existingRule.attributes, + actions: [ + { + actionRef: 'action_0', + actionTypeId: 'test-1', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + group: 'default', + params: {}, + uuid: '103', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + params: {}, + uuid: '104', + }, + ], + apiKey: null, + apiKeyOwner: null, + apiKeyCreatedByUser: null, + meta: { versionApiKeyLastmodified: 'v8.2.0' }, + name: 'my rule name', + enabled: false, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + tags: ['foo'], + revision: 1, + }, + references: [{ id: '1', name: 'action_0', type: 'action' }], + }, + ], + { overwrite: true } + ); + + expect(result.rules[0]).toEqual({ + ...omit(existingRule.attributes, 'legacyId'), + createdAt: new Date(existingRule.attributes.createdAt), + updatedAt: new Date(existingRule.attributes.updatedAt), + executionStatus: { + ...existingRule.attributes.executionStatus, + lastExecutionDate: new Date(existingRule.attributes.executionStatus.lastExecutionDate), + }, + actions: [{ ...defaultAction, actionTypeId: 'test-1', uuid: '222' }], + systemActions: [{ ...systemAction, actionTypeId: 'test-2', uuid: '222' }], + id: existingRule.id, + snoozeSchedule: [], + }); + }); + + test('should construct the refs correctly and persist the actions correctly', async () => { + const defaultAction = { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + id: '1', + params: {}, + }; + + const systemAction = { + id: 'system_action-id', + params: {}, + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + ...existingRule, + attributes: { + ...existingRule.attributes, + actions: [ + { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + params: {}, + actionRef: 'action_0', + actionTypeId: 'test-1', + uuid: '222', + }, + { + params: {}, + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + uuid: '222', + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + ], + }); + + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test-1', + config: {}, + isMissingSecrets: false, + name: 'test default connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [defaultAction, systemAction], + }, + ], + }); + + const rule = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0] as Array< + SavedObject + >; + + expect(rule[0].attributes.actions).toEqual([ + { + actionRef: 'action_0', + actionTypeId: 'test-1', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + group: 'default', + params: {}, + uuid: '105', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + params: {}, + uuid: '106', + }, + ]); + }); + + test('should transforms the actions correctly', async () => { + const defaultAction = { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + id: '1', + params: {}, + }; + + const systemAction = { + id: 'system_action-id', + params: {}, + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [ + { + ...existingRule, + attributes: { + ...existingRule.attributes, + actions: [ + { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + params: {}, + actionRef: 'action_0', + actionTypeId: 'test-1', + uuid: '222', + }, + { + params: {}, + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + uuid: '222', + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + ], + }); + + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test-1', + config: {}, + isMissingSecrets: false, + name: 'test default connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [defaultAction, systemAction], + }, + ], + }); + + expect(result.rules[0].actions).toEqual([ + { ...defaultAction, actionTypeId: 'test-1', uuid: '222' }, + ]); + expect(result.rules[0].systemActions).toEqual([ + { ...systemAction, actionTypeId: 'test-2', uuid: '222' }, + ]); + }); + + it('should return an error if the action does not have the right attributes', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + }; + + actionsClient.isSystemAction.mockReturnValue(false); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [action], + }, + ], + }); + + expect(result).toMatchInlineSnapshot(` + Object { + "errors": Array [ + Object { + "message": "Error validating bulk edit rules operations - [0.group]: expected value of type [string] but got [undefined]", + "rule": Object { + "id": "1", + "name": "my rule name", + }, + }, + ], + "rules": Array [], + "skipped": Array [], + "total": 1, + } + `); + }); + + it('should throw an error if the system action contains the group', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + group: 'default', + }; + + actionsClient.isSystemAction.mockReturnValue(true); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + const res = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [action], + }, + ], + }); + + expect(res).toEqual({ + errors: [ + { + message: + 'Error validating bulk edit rules operations - [0.group]: definition for this key is missing', + rule: { + id: '1', + name: 'my rule name', + }, + }, + ], + rules: [], + skipped: [], + total: 1, + }); + }); + + it('should throw an error if the system action contains the frequency', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + }; + + actionsClient.isSystemAction.mockReturnValue(true); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + const res = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [action], + }, + ], + }); + + expect(res).toEqual({ + errors: [ + { + message: + 'Error validating bulk edit rules operations - [0.frequency]: definition for this key is missing', + rule: { + id: '1', + name: 'my rule name', + }, + }, + ], + rules: [], + skipped: [], + total: 1, + }); + }); + + it('should throw an error if the system action contains the alertsFilter', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + alertsFilter: { + query: { kql: 'test:1', filters: [] }, + }, + }; + + actionsClient.isSystemAction.mockReturnValue(true); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + const res = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [action], + }, + ], + }); + + expect(res).toEqual({ + errors: [ + { + message: + 'Error validating bulk edit rules operations - [0.alertsFilter]: definition for this key is missing', + rule: { + id: '1', + name: 'my rule name', + }, + }, + ], + rules: [], + skipped: [], + total: 1, + }); + }); + + it('should throw an error if the same system action is used twice', async () => { + const action = { + id: 'system_action-id', + uuid: '123', + params: {}, + }; + + actionsClient.isSystemAction.mockReturnValue(true); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: 'test-2', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + const res = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [action, action], + }, + ], + }); + + expect(res).toEqual({ + errors: [ + { + message: 'Cannot use the same system action twice', + rule: { + id: '1', + name: 'my rule name', + }, + }, + ], + rules: [], + skipped: [], + total: 1, }); }); + + it('should throw an error if the default action does not contain the group', async () => { + const action = { + id: '1', + params: {}, + }; + + actionsClient.isSystemAction.mockReturnValue(false); + + await expect( + rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'actions', + operation: 'add', + value: [action], + }, + ], + }) + ).resolves.toMatchInlineSnapshot(` + Object { + "errors": Array [ + Object { + "message": "Error validating bulk edit rules operations - [0.group]: expected value of type [string] but got [undefined]", + "rule": Object { + "id": "1", + "name": "my rule name", + }, + }, + ], + "rules": Array [], + "skipped": Array [], + "total": 1, + } + `); + }); }); describe('index pattern operations', () => { @@ -969,7 +1638,13 @@ describe('bulkEdit()', () => { const result = await rulesClient.bulkEdit({ filter: '', - operations: [], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-tag'], + }, + ], paramsModifier, }); @@ -1038,7 +1713,13 @@ describe('bulkEdit()', () => { const result = await rulesClient.bulkEdit({ filter: '', - operations: [], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-tag'], + }, + ], paramsModifier, }); @@ -1063,6 +1744,9 @@ describe('bulkEdit()', () => { }); test('should skip operation when params modifiers does not modify index pattern array', async () => { + const originalValidate = bulkEditOperationsSchema.validate; + bulkEditOperationsSchema.validate = jest.fn(); + paramsModifier.mockResolvedValue({ modifiedParams: { index: ['test-1', 'test-2'], @@ -1081,6 +1765,8 @@ describe('bulkEdit()', () => { expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(0); expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(0); + + bulkEditOperationsSchema.validate = originalValidate; }); }); @@ -2325,8 +3011,8 @@ describe('bulkEdit()', () => { async executor() { return { state: {} }; }, - category: 'test', producer: 'alerts', + category: 'test', validLegacyConsumers: [], }); @@ -2371,8 +3057,8 @@ describe('bulkEdit()', () => { async executor() { return { state: {} }; }, - category: 'test', producer: 'alerts', + category: 'test', validLegacyConsumers: [], }); @@ -2411,7 +3097,13 @@ describe('bulkEdit()', () => { const result = await rulesClient.bulkEdit({ filter: '', - operations: [], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], paramsModifier: async (params) => { params.index = ['test-index-*']; @@ -2546,6 +3238,49 @@ describe('bulkEdit()', () => { expect(validateScheduleLimit).toHaveBeenCalledTimes(1); }); + + test('should not validate scheduling on system actions', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...existingDecryptedRule, + attributes: { + ...existingDecryptedRule.attributes, + actions: [ + { + actionRef: 'action_0', + actionTypeId: 'test', + params: {}, + uuid: '111', + }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any, + }, + ], + }); + + const result = await rulesClient.bulkEdit({ + operations: [ + { + field: 'schedule', + operation: 'set', + value: { interval: '10m' }, + }, + ], + }); + + expect(result.errors).toHaveLength(0); + expect(result.rules).toHaveLength(1); + }); }); describe('paramsModifier', () => { @@ -2579,7 +3314,13 @@ describe('bulkEdit()', () => { const result = await rulesClient.bulkEdit({ filter: '', - operations: [], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], paramsModifier: async (params) => { params.index = ['test-index-*']; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts index 1a4898418a8c4..53508a2de0ceb 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.ts @@ -15,6 +15,8 @@ import { SavedObjectsFindResult, SavedObjectsUpdateResponse, } from '@kbn/core/server'; +import { validateSystemActions } from '../../../../lib/validate_system_actions'; +import { RuleAction, RuleSystemAction } from '../../../../../common'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; import { BulkActionSkipResult } from '../../../../../common/bulk_edit'; import { RuleTypeRegistry } from '../../../../types'; @@ -32,10 +34,10 @@ import { retryIfBulkEditConflicts, applyBulkEditOperation, buildKueryNodeFilter, - injectReferencesIntoActions, getBulkSnooze, getBulkUnsnooze, verifySnoozeScheduleLimit, + injectReferencesIntoActions, } from '../../../../rules_client/common'; import { alertingAuthorizationFilterOpts, @@ -56,6 +58,7 @@ import { RuleBulkOperationAggregation, RulesClientContext, NormalizedAlertActionWithGeneratedValues, + NormalizedAlertAction, } from '../../../../rules_client/types'; import { migrateLegacyActions } from '../../../../rules_client/lib'; import { @@ -78,6 +81,10 @@ import { transformRuleDomainToRule, } from '../../transforms'; import { validateScheduleLimit, ValidateScheduleLimitResult } from '../get_schedule_frequency'; +import { + bulkEditDefaultActionsSchema, + bulkEditSystemActionsSchema, +} from './schemas/bulk_edit_rules_option_schemas'; const isValidInterval = (interval: string | undefined): interval is string => { return interval !== undefined; @@ -117,6 +124,7 @@ export async function bulkEditRules( ): Promise> { const queryFilter = (options as BulkEditOptionsFilter).filter; const ids = (options as BulkEditOptionsIds).ids; + const actionsClient = await context.getActionsClient(); if (ids && queryFilter) { throw Boom.badRequest( @@ -231,13 +239,17 @@ export async function bulkEditRules( // fix the type cast from SavedObjectsBulkUpdateObject to SavedObjectsBulkUpdateObject // when we are doing the bulk create and this should fix itself const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId!); - const ruleDomain = transformRuleAttributesToRuleDomain(attributes as RuleAttributes, { - id, - logger: context.logger, - ruleType, - references, - omitGeneratedValues: false, - }); + const ruleDomain = transformRuleAttributesToRuleDomain( + attributes as RuleAttributes, + { + id, + logger: context.logger, + ruleType, + references, + omitGeneratedValues: false, + }, + (connectorId: string) => actionsClient.isSystemAction(connectorId) + ); try { ruleDomainSchema.validate(ruleDomain); } catch (e) { @@ -358,7 +370,6 @@ async function bulkEditRulesOcc( skipped: [], }; } - const { result, apiKeysToInvalidate } = rules.length > 0 ? await saveBulkUpdatedRules({ @@ -478,7 +489,8 @@ async function updateRuleAttributesAndParamsInMemory( logger: context.logger, ruleType: context.ruleTypeRegistry.get(rule.attributes.alertTypeId), references: rule.references, - } + }, + context.isSystemAction ); const { @@ -490,7 +502,7 @@ async function updateRuleAttributesAndParamsInMemory( context, operations, rule: ruleDomain, - ruleActions: ruleActions as RuleDomain['actions'], // TODO (http-versioning) Remove this cast once we fix injectReferencesIntoActions + ruleActions, ruleType, }); @@ -532,9 +544,9 @@ async function updateRuleAttributesAndParamsInMemory( ); const { - actions: rawAlertActions, references, params: updatedParams, + actions: actionsWithRefs, } = await extractReferences( context, ruleType, @@ -542,10 +554,13 @@ async function updateRuleAttributesAndParamsInMemory( validatedMutatedAlertTypeParams ); - const ruleAttributes = transformRuleDomainToRuleAttributes(updatedRule, { - legacyId: rule.attributes.legacyId, - actionsWithRefs: rawAlertActions, - paramsWithRefs: updatedParams as RuleAttributes['params'], + const ruleAttributes = transformRuleDomainToRuleAttributes({ + actionsWithRefs, + rule: updatedRule, + params: { + legacyId: rule.attributes.legacyId, + paramsWithRefs: updatedParams as RuleAttributes['params'], + }, }); const { apiKeyAttributes } = await prepareApiKeys( @@ -563,7 +578,7 @@ async function updateRuleAttributesAndParamsInMemory( ruleAttributes, apiKeyAttributes, updatedParams, - rawAlertActions, + ruleAttributes.actions, username ); @@ -621,9 +636,11 @@ async function getUpdatedAttributesFromOperations({ context: RulesClientContext; operations: BulkEditOperation[]; rule: RuleDomain; - ruleActions: RuleDomain['actions']; + ruleActions: RuleDomain['actions'] | RuleDomain['systemActions']; ruleType: RuleType; }) { + const actionsClient = await context.getActionsClient(); + let updatedRule = cloneDeep(rule); let updatedRuleActions = ruleActions; let hasUpdateApiKeyOperation = false; @@ -636,21 +653,53 @@ async function getUpdatedAttributesFromOperations({ // the `isAttributesUpdateSkipped` flag to false. switch (operation.field) { case 'actions': { + const systemActions = operation.value.filter((action): action is RuleSystemAction => + actionsClient.isSystemAction(action.id) + ); + if (systemActions.length > 0) { + try { + bulkEditSystemActionsSchema.validate(systemActions); + } catch (error) { + throw Boom.badRequest(`Error validating bulk edit rules operations - ${error.message}`); + } + } + + const defaultActions = operation.value.filter( + (action): action is RuleAction => !actionsClient.isSystemAction(action.id) + ); + if (defaultActions.length > 0) { + try { + bulkEditDefaultActionsSchema.validate(defaultActions); + } catch (error) { + throw Boom.badRequest(`Error validating bulk edit rules operations - ${error.message}`); + } + } + + const { actions: genActions, systemActions: genSystemActions } = + await addGeneratedActionValues(defaultActions, systemActions, context); const updatedOperation = { ...operation, - value: await addGeneratedActionValues(operation.value, context), + value: [...genActions, ...genSystemActions], }; + await validateSystemActions({ + actionsClient, + connectorAdapterRegistry: context.connectorAdapterRegistry, + systemActions: genSystemActions, + }); + try { await validateActions(context, ruleType, { ...updatedRule, - actions: updatedOperation.value, + actions: genActions, + systemActions: genSystemActions, }); } catch (e) { // If validateActions fails on the first attempt, it may be because of legacy rule-level frequency params updatedRule = await attemptToMigrateLegacyFrequency( context, - updatedOperation, + operation.field, + genActions, updatedRule, ruleType ); @@ -669,23 +718,27 @@ async function getUpdatedAttributesFromOperations({ break; } + case 'snoozeSchedule': { if (operation.operation === 'set') { const snoozeAttributes = getBulkSnooze( updatedRule, operation.value as RuleSnoozeSchedule ); + try { verifySnoozeScheduleLimit(snoozeAttributes.snoozeSchedule); } catch (error) { throw Error(`Error updating rule: could not add snooze - ${error.message}`); } + updatedRule = { ...updatedRule, muteAll: snoozeAttributes.muteAll, snoozeSchedule: snoozeAttributes.snoozeSchedule as RuleDomain['snoozeSchedule'], }; } + if (operation.operation === 'delete') { const idsToDelete = operation.value && [...operation.value]; if (idsToDelete?.length === 0) { @@ -702,18 +755,25 @@ async function getUpdatedAttributesFromOperations({ snoozeSchedule: snoozeAttributes.snoozeSchedule as RuleDomain['snoozeSchedule'], }; } + isAttributesUpdateSkipped = false; break; } + case 'apiKey': { hasUpdateApiKeyOperation = true; isAttributesUpdateSkipped = false; break; } + default: { if (operation.field === 'schedule') { - validateScheduleOperation(operation.value, updatedRule.actions, rule.id); + const defaultActions = updatedRule.actions.filter( + (action) => !actionsClient.isSystemAction(action.id) + ); + validateScheduleOperation(operation.value, defaultActions, rule.id); } + const { modifiedAttributes, isAttributeModified } = applyBulkEditOperation( operation, updatedRule @@ -923,18 +983,19 @@ async function saveBulkUpdatedRules({ async function attemptToMigrateLegacyFrequency( context: RulesClientContext, - operation: BulkEditOperation, + operationField: BulkEditOperation['field'], + actions: NormalizedAlertAction[], rule: RuleDomain, ruleType: RuleType ) { - if (operation.field !== 'actions') + if (operationField !== 'actions') throw new Error('Can only perform frequency migration on an action operation'); // Try to remove the rule-level frequency params, and then validate actions if (typeof rule.notifyWhen !== 'undefined') rule.notifyWhen = undefined; if (rule.throttle) rule.throttle = undefined; await validateActions(context, ruleType, { ...rule, - actions: operation.value, + actions, }); return rule; } diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/schemas/bulk_edit_rules_option_schemas.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/schemas/bulk_edit_rules_option_schemas.ts index f80d63210cf4a..e133908cc31cd 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/schemas/bulk_edit_rules_option_schemas.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/schemas/bulk_edit_rules_option_schemas.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; import { rRuleRequestSchema } from '../../../../r_rule/schemas'; -import { notifyWhenSchema } from '../../../schemas'; +import { notifyWhenSchema, actionAlertsFilterSchema } from '../../../schemas'; import { validateDuration } from '../../../validation'; import { validateSnoozeSchedule } from '../validation'; @@ -26,7 +26,7 @@ const bulkEditRuleSnoozeScheduleSchemaWithValidation = schema.object( { validate: validateSnoozeSchedule } ); -const bulkEditActionSchema = schema.object({ +const bulkEditDefaultActionSchema = schema.object({ group: schema.string(), id: schema.string(), params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), @@ -38,8 +38,19 @@ const bulkEditActionSchema = schema.object({ notifyWhen: notifyWhenSchema, }) ), + alertsFilter: schema.maybe(actionAlertsFilterSchema), }); +export const bulkEditDefaultActionsSchema = schema.arrayOf(bulkEditDefaultActionSchema); + +export const bulkEditSystemActionSchema = schema.object({ + id: schema.string(), + params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }), + uuid: schema.maybe(schema.string()), +}); + +export const bulkEditSystemActionsSchema = schema.arrayOf(bulkEditSystemActionSchema); + const bulkEditTagSchema = schema.object({ operation: schema.oneOf([schema.literal('add'), schema.literal('delete'), schema.literal('set')]), field: schema.literal('tags'), @@ -49,7 +60,7 @@ const bulkEditTagSchema = schema.object({ const bulkEditActionsSchema = schema.object({ operation: schema.oneOf([schema.literal('add'), schema.literal('set')]), field: schema.literal('actions'), - value: schema.arrayOf(bulkEditActionSchema), + value: schema.arrayOf(schema.oneOf([bulkEditDefaultActionSchema, bulkEditSystemActionSchema])), }); const bulkEditScheduleSchema = schema.object({ diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts index 78485744e8103..f2de0ed7840dd 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts @@ -22,6 +22,7 @@ import { AlertingAuthorization } from '../../../../authorization/alerting_author import { alertsServiceMock } from '../../../../alerts_service/alerts_service.mock'; import { ALERT_RULE_UUID, ALERT_UUID } from '@kbn/rule-data-utils'; import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -61,6 +62,8 @@ const rulesClientParams: jest.Mocked = { getAlertIndicesAlias: jest.fn(), alertsService, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), }; describe('bulkUntrackAlerts()', () => { diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts index 07063b4fee4af..7d1fe07d8a7a4 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts @@ -28,6 +28,10 @@ import { getBeforeSetup, setGlobalDate } from '../../../../rules_client/tests/li import { RecoveredActionGroup } from '../../../../../common'; import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { getRuleExecutionStatusPending, getDefaultMonitoring } from '../../../../lib'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { ConnectorAdapter } from '../../../../connector_adapters/types'; +import { RuleDomain } from '../../types'; +import { RuleSystemAction } from '../../../../types'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; jest.mock('../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ @@ -61,6 +65,7 @@ const authorization = alertingAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); const auditLogger = auditLoggerMock.create(); const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const connectorAdapterRegistry = new ConnectorAdapterRegistry(); const kibanaVersion = 'v8.0.0'; const rulesClientParams: jest.Mocked = { @@ -86,6 +91,8 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + connectorAdapterRegistry, + isSystemAction: jest.fn(), uiSettings: uiSettingsServiceMock.createStartContract(), }; @@ -153,6 +160,9 @@ describe('create()', () => { isSystemAction: false, }, ]); + + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + taskManager.schedule.mockResolvedValue({ id: 'task-123', taskType: 'alerting:123', @@ -166,6 +176,7 @@ describe('create()', () => { params: {}, ownerId: null, }); + rulesClientParams.getActionsClient.mockResolvedValue(actionsClient); }); @@ -192,6 +203,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -346,12 +358,14 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, }, ], }; + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: RULE_SAVED_OBJECT_TYPE, @@ -368,6 +382,7 @@ describe('create()', () => { }, ], }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: RULE_SAVED_OBJECT_TYPE, @@ -385,13 +400,16 @@ describe('create()', () => { }, ], }); + const result = await rulesClient.create({ data }); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ entity: 'rule', consumer: 'bar', operation: 'create', ruleTypeId: '123', }); + expect(result).toMatchInlineSnapshot(` Object { "actions": Array [ @@ -402,6 +420,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -426,6 +445,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "tags": Array [ "foo", ], @@ -577,6 +597,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -641,6 +662,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -818,6 +840,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -826,6 +849,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_1', actionTypeId: 'test', + uuid: 'test-uuid-1', params: { foo: true, }, @@ -834,6 +858,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_2', actionTypeId: 'test2', + uuid: 'test-uuid-2', params: { foo: true, }, @@ -878,6 +903,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, Object { "actionTypeId": "test", @@ -886,6 +912,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid-1", }, Object { "actionTypeId": "test2", @@ -894,6 +921,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid-2", }, ], "alertTypeId": "123", @@ -912,6 +940,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -997,10 +1026,10 @@ describe('create()', () => { isSystemAction: false, }, ]); + actionsClient.isPreconfigured.mockReset(); - actionsClient.isPreconfigured.mockReturnValueOnce(false); - actionsClient.isPreconfigured.mockReturnValueOnce(true); - actionsClient.isPreconfigured.mockReturnValueOnce(false); + actionsClient.isPreconfigured.mockImplementation((id) => id === 'preconfigured'); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: RULE_SAVED_OBJECT_TYPE, @@ -1019,6 +1048,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1027,6 +1057,7 @@ describe('create()', () => { group: 'default', actionRef: 'preconfigured:preconfigured', actionTypeId: 'test', + uuid: 'test-uuid-1', params: { foo: true, }, @@ -1035,6 +1066,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_2', actionTypeId: 'test2', + uuid: 'test-uuid-2', params: { foo: true, }, @@ -1075,6 +1107,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, Object { "actionTypeId": "test", @@ -1083,6 +1116,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid-1", }, Object { "actionTypeId": "test2", @@ -1091,6 +1125,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid-2", }, ], "alertTypeId": "123", @@ -1109,6 +1144,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -1194,11 +1230,6 @@ describe('create()', () => { foo: true, }, }, - { - group: 'default', - id: 'system_action-id', - params: {}, - }, { group: 'default', id: '2', @@ -1207,6 +1238,12 @@ describe('create()', () => { }, }, ], + systemActions: [ + { + id: 'system_action-id', + params: {}, + }, + ], }); actionsClient.getBulk.mockReset(); @@ -1257,11 +1294,6 @@ describe('create()', () => { }, ]); - actionsClient.isSystemAction.mockReset(); - actionsClient.isSystemAction.mockReturnValueOnce(false); - actionsClient.isSystemAction.mockReturnValueOnce(true); - actionsClient.isSystemAction.mockReturnValueOnce(false); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: RULE_SAVED_OBJECT_TYPE, @@ -1285,14 +1317,13 @@ describe('create()', () => { }, }, { - group: 'default', actionRef: 'system_action:system_action-id', actionTypeId: 'test', params: {}, }, { group: 'default', - actionRef: 'action_2', + actionRef: 'action_1', actionTypeId: 'test2', params: { foo: true, @@ -1308,7 +1339,7 @@ describe('create()', () => { id: '1', }, { - name: 'action_2', + name: 'action_1', type: 'action', id: '2', }, @@ -1337,12 +1368,7 @@ describe('create()', () => { "params": Object { "foo": true, }, - }, - Object { - "actionTypeId": "test", - "group": "default", - "id": "system_action-id", - "params": Object {}, + "uuid": undefined, }, Object { "actionTypeId": "test2", @@ -1351,6 +1377,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, ], "alertTypeId": "123", @@ -1369,6 +1396,14 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [ + Object { + "actionTypeId": "test", + "id": "system_action-id", + "params": Object {}, + "uuid": undefined, + }, + ], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -1378,55 +1413,78 @@ describe('create()', () => { { actions: [ { - group: 'default', actionRef: 'action_0', actionTypeId: 'test', + group: 'default', params: { foo: true, }, uuid: '111', }, { - group: 'default', - actionRef: 'system_action:system_action-id', - actionTypeId: 'test', - params: {}, - uuid: '112', - }, - { - group: 'default', - actionRef: 'action_2', + actionRef: 'action_1', actionTypeId: 'test2', + group: 'default', params: { foo: true, }, + uuid: '112', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: 'test', + params: {}, uuid: '113', }, ], alertTypeId: '123', apiKey: null, - apiKeyOwner: null, apiKeyCreatedByUser: null, + apiKeyOwner: null, consumer: 'bar', createdAt: '2019-02-12T21:01:22.479Z', createdBy: 'elastic', enabled: true, - legacyId: null, executionStatus: { lastExecutionDate: '2019-02-12T21:01:22.479Z', status: 'pending', }, - monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), - meta: { versionApiKeyLastmodified: kibanaVersion }, + legacyId: null, + meta: { + versionApiKeyLastmodified: 'v8.0.0', + }, + monitoring: { + run: { + calculated_metrics: { + success_ratio: 0, + }, + history: [], + last_run: { + metrics: { + duration: 0, + gap_duration_s: null, + total_alerts_created: null, + total_alerts_detected: null, + total_indexing_duration_ms: null, + total_search_duration_ms: null, + }, + timestamp: '2019-02-12T21:01:22.479Z', + }, + }, + }, muteAll: false, - snoozeSchedule: [], mutedInstanceIds: [], name: 'abc', notifyWhen: null, - params: { bar: true }, + params: { + bar: true, + }, revision: 0, running: false, - schedule: { interval: '1m' }, + schedule: { + interval: '1m', + }, + snoozeSchedule: [], tags: ['foo'], throttle: null, updatedAt: '2019-02-12T21:01:22.479Z', @@ -1436,11 +1494,10 @@ describe('create()', () => { id: 'mock-saved-object-id', references: [ { id: '1', name: 'action_0', type: 'action' }, - { id: '2', name: 'action_2', type: 'action' }, + { id: '2', name: 'action_1', type: 'action' }, ], } ); - expect(actionsClient.isSystemAction).toHaveBeenCalledTimes(3); }); test('creates a disabled rule', async () => { @@ -1465,6 +1522,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1490,6 +1548,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -1508,6 +1567,7 @@ describe('create()', () => { "schedule": Object { "interval": 10000, }, + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -1582,6 +1642,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1680,6 +1741,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -1699,6 +1761,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -1738,6 +1801,7 @@ describe('create()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', useSavedObjectReferences: { extractReferences: extractReferencesFn, @@ -1746,7 +1810,6 @@ describe('create()', () => { validate: { params: { validate: (params) => params }, }, - validLegacyConsumers: [], })); const data = getMockData({ params: ruleParams, @@ -1770,6 +1833,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1869,6 +1933,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -1888,6 +1953,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -1915,6 +1981,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -1957,6 +2024,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2041,6 +2109,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -2065,6 +2134,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "tags": Array [ "foo", ], @@ -2098,6 +2168,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2182,6 +2253,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -2206,6 +2278,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "tags": Array [ "foo", ], @@ -2239,6 +2312,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2323,6 +2397,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -2347,6 +2422,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "tags": Array [ "foo", ], @@ -2388,6 +2464,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2505,6 +2582,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -2531,6 +2609,7 @@ describe('create()', () => { "interval": "10s", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "tags": Array [ "foo", ], @@ -2552,6 +2631,8 @@ describe('create()', () => { name: 'Default', }, ], + category: 'test', + validLegacyConsumers: [], defaultActionGroupId: 'default', recoveryActionGroup: RecoveredActionGroup, validate: { @@ -2565,9 +2646,7 @@ describe('create()', () => { async executor() { return { state: {} }; }, - category: 'test', producer: 'alerts', - validLegacyConsumers: [], }); await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( `"params invalid: [param1]: expected value of type [string] but got [undefined]"` @@ -2624,6 +2703,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2673,6 +2753,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2720,6 +2801,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2778,6 +2860,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -2881,6 +2964,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -3035,6 +3119,7 @@ describe('create()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', useSavedObjectReferences: { extractReferences: jest.fn(), @@ -3043,7 +3128,6 @@ describe('create()', () => { validate: { params: { validate: (params) => params }, }, - validLegacyConsumers: [], })); const createdAttributes = { ...data, @@ -3064,6 +3148,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -3109,6 +3194,7 @@ describe('create()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', useSavedObjectReferences: { extractReferences: jest.fn(), @@ -3117,7 +3203,6 @@ describe('create()', () => { validate: { params: { validate: (params) => params }, }, - validLegacyConsumers: [], })); const data = getMockData({ schedule: { interval: '1s' } }); @@ -3133,6 +3218,7 @@ describe('create()', () => { ...rulesClientParams, minimumScheduleInterval: { value: '1m', enforce: true }, }); + ruleTypeRegistry.get.mockImplementation(() => ({ id: '123', name: 'Test', @@ -3148,6 +3234,7 @@ describe('create()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', useSavedObjectReferences: { extractReferences: jest.fn(), @@ -3156,7 +3243,6 @@ describe('create()', () => { validate: { params: { validate: (params) => params }, }, - validLegacyConsumers: [], })); const data = getMockData({ @@ -3242,6 +3328,7 @@ describe('create()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', useSavedObjectReferences: { extractReferences: jest.fn(), @@ -3250,7 +3337,6 @@ describe('create()', () => { validate: { params: { validate: (params) => params }, }, - validLegacyConsumers: [], })); const data = getMockData({ @@ -3293,6 +3379,7 @@ describe('create()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', useSavedObjectReferences: { extractReferences: jest.fn(), @@ -3301,7 +3388,6 @@ describe('create()', () => { validate: { params: { validate: (params) => params }, }, - validLegacyConsumers: [], })); const data = getMockData({ @@ -3357,6 +3443,7 @@ describe('create()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', useSavedObjectReferences: { extractReferences: jest.fn(), @@ -3365,7 +3452,6 @@ describe('create()', () => { validate: { params: { validate: (params) => params }, }, - validLegacyConsumers: [], })); const data = getMockData({ @@ -3439,6 +3525,7 @@ describe('create()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', useSavedObjectReferences: { extractReferences: jest.fn(), @@ -3447,7 +3534,6 @@ describe('create()', () => { validate: { params: { validate: (params) => params }, }, - validLegacyConsumers: [], })); const data = getMockData({ @@ -3553,6 +3639,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: '.slack', + uuid: 'test-uuid', params: { foo: true, }, @@ -3597,6 +3684,7 @@ describe('create()', () => { "params": Object { "foo": true, }, + "uuid": "test-uuid", }, ], "alertTypeId": "123", @@ -3614,6 +3702,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -3640,6 +3729,7 @@ describe('create()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', useSavedObjectReferences: { extractReferences: jest.fn(), @@ -3653,7 +3743,6 @@ describe('create()', () => { mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, shouldWrite: true, }, - validLegacyConsumers: [], })); const data = getMockData({ @@ -3699,6 +3788,7 @@ describe('create()', () => { return { state: {} }; }, category: 'test', + validLegacyConsumers: [], producer: 'alerts', useSavedObjectReferences: { extractReferences: jest.fn(), @@ -3707,7 +3797,6 @@ describe('create()', () => { validate: { params: { validate: (params) => params }, }, - validLegacyConsumers: [], })); const data = getMockData({ @@ -3761,6 +3850,7 @@ describe('create()', () => { group: 'default', actionRef: 'action_0', actionTypeId: 'test', + uuid: 'test-uuid', params: { foo: true, }, @@ -3870,4 +3960,398 @@ describe('create()', () => { expect.any(Object) ); }); + + describe('actions', () => { + const connectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test', + ruleActionParamsSchema: schema.object({ foo: schema.string() }), + buildActionParams: jest.fn(), + }; + + connectorAdapterRegistry.register(connectorAdapter); + + beforeEach(() => { + actionsClient.getBulk.mockReset(); + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: '.test', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + executionStatus: getRuleExecutionStatusPending('2019-02-12T21:01:22.479Z'), + alertTypeId: '123', + schedule: { interval: '1m' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + notifyWhen: null, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + uuid: 'test-uuid', + params: { + foo: true, + }, + }, + { + group: 'default', + actionRef: 'system_action:system_action-id', + actionTypeId: 'test', + uuid: 'test-uuid-1', + params: { foo: 'test' }, + }, + ], + running: false, + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + }); + + test('create a rule with system actions and default actions', async () => { + const data = getMockData({ + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + systemActions: [ + { + id: 'system_action-id', + params: { + foo: 'test', + }, + }, + ], + }); + + const result = await rulesClient.create({ data }); + + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + "uuid": "test-uuid", + }, + ], + "alertTypeId": "123", + "createdAt": 2019-02-12T21:01:22.479Z, + "executionStatus": Object { + "lastExecutionDate": 2019-02-12T21:01:22.000Z, + "status": "pending", + }, + "id": "1", + "notifyWhen": null, + "params": Object { + "bar": true, + }, + "running": false, + "schedule": Object { + "interval": "1m", + }, + "scheduledTaskId": "task-123", + "systemActions": Array [ + Object { + "actionTypeId": "test", + "id": "system_action-id", + "params": Object { + "foo": "test", + }, + "uuid": "test-uuid-1", + }, + ], + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'alert', + { + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '156', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: '.test', + params: { foo: 'test' }, + uuid: '157', + }, + ], + alertTypeId: '123', + apiKey: null, + apiKeyOwner: null, + apiKeyCreatedByUser: null, + consumer: 'bar', + createdAt: '2019-02-12T21:01:22.479Z', + createdBy: 'elastic', + enabled: true, + legacyId: null, + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + }, + monitoring: getDefaultMonitoring('2019-02-12T21:01:22.479Z'), + meta: { versionApiKeyLastmodified: kibanaVersion }, + muteAll: false, + snoozeSchedule: [], + mutedInstanceIds: [], + name: 'abc', + notifyWhen: null, + params: { bar: true }, + revision: 0, + running: false, + schedule: { interval: '1m' }, + tags: ['foo'], + throttle: null, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + }, + { + id: 'mock-saved-object-id', + references: [{ id: '1', name: 'action_0', type: 'action' }], + } + ); + }); + + test('should construct the refs correctly and persist the actions to ES correctly', async () => { + const data = getMockData({ + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + systemActions: [ + { + id: 'system_action-id', + params: { + foo: 'test', + }, + }, + ], + }); + + await rulesClient.create({ data }); + + const rule = unsecuredSavedObjectsClient.create.mock.calls[0][1] as RuleDomain; + + expect(rule.actions).toEqual([ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '158', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: '.test', + params: { foo: 'test' }, + uuid: '159', + }, + ]); + }); + + test('should transforms the actions from ES correctly', async () => { + const data = getMockData({ + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + systemActions: [ + { + id: 'system_action-id', + params: { + foo: 'test', + }, + }, + ], + }); + + const result = await rulesClient.create({ data }); + + expect(result.actions).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + "uuid": "test-uuid", + }, + ] + `); + + expect(result.systemActions).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": "test", + "id": "system_action-id", + "params": Object { + "foo": "test", + }, + "uuid": "test-uuid-1", + }, + ] + `); + }); + + test('should throw an error if the system action does not exist', async () => { + const systemAction: RuleSystemAction = { + id: 'fake-system-action', + uuid: '123', + params: {}, + actionTypeId: '.test', + }; + + const data = getMockData({ actions: [], systemActions: [systemAction] }); + await expect(() => rulesClient.create({ data })).rejects.toMatchInlineSnapshot( + `[Error: Action fake-system-action is not a system action]` + ); + }); + + test('should throw an error if the system action contains the group', async () => { + const systemAction = { + id: 'system_action-id', + uuid: '123', + params: {}, + actionTypeId: '.test', + group: 'default', + }; + + const data = getMockData({ actions: [], systemActions: [systemAction] }); + await expect(() => rulesClient.create({ data })).rejects.toMatchInlineSnapshot( + `[Error: Error validating create data - [systemActions.0.group]: definition for this key is missing]` + ); + }); + + test('should throw an error if the system action contains the frequency', async () => { + const systemAction = { + id: 'system_action-id', + uuid: '123', + params: {}, + actionTypeId: '.test', + frequency: { + summary: false, + notifyWhen: 'onActionGroupChange', + throttle: null, + }, + }; + + const data = getMockData({ actions: [], systemActions: [systemAction] }); + await expect(() => rulesClient.create({ data })).rejects.toMatchInlineSnapshot( + `[Error: Error validating create data - [systemActions.0.frequency]: definition for this key is missing]` + ); + }); + + test('should throw an error if the system action contains the alertsFilter', async () => { + const systemAction = { + id: 'system_action-id', + uuid: '123', + params: {}, + actionTypeId: '.test', + alertsFilter: { + query: { kql: 'test:1', filters: [] }, + }, + }; + + const data = getMockData({ systemActions: [systemAction] }); + await expect(() => rulesClient.create({ data })).rejects.toMatchInlineSnapshot( + `[Error: Error validating create data - [systemActions.0.alertsFilter]: definition for this key is missing]` + ); + }); + + test('should throw an error if the default action does not contain the group', async () => { + const action = { + id: 'action-id-1', + params: {}, + actionTypeId: '.test', + }; + + const data = getMockData({ actions: [action] }); + await expect(() => rulesClient.create({ data })).rejects.toMatchInlineSnapshot( + `[Error: Error validating create data - [actions.0.group]: expected value of type [string] but got [undefined]]` + ); + }); + + test('should throw an error if the same system action is used twice', async () => { + const systemAction: RuleSystemAction = { + id: 'system_action-id', + uuid: '123', + params: { foo: 'test' }, + actionTypeId: '.test', + }; + + const data = getMockData({ actions: [], systemActions: [systemAction, systemAction] }); + await expect(() => rulesClient.create({ data })).rejects.toMatchInlineSnapshot( + `[Error: Cannot use the same system action twice]` + ); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.ts index ef55b67ef08db..7de8c7effa8f3 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.ts @@ -8,6 +8,7 @@ import Semver from 'semver'; import Boom from '@hapi/boom'; import { SavedObject, SavedObjectsUtils } from '@kbn/core/server'; import { withSpan } from '@kbn/apm-utils'; +import { validateSystemActions } from '../../../../lib/validate_system_actions'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; import { parseDuration, getRuleCircuitBreakerErrorMessage } from '../../../../../common'; import { WriteOperations, AlertingAuthorizationEntity } from '../../../../authorization'; @@ -24,7 +25,7 @@ import { } from '../../../../rules_client/lib'; import { generateAPIKeyName, apiKeyAsRuleDomainProperties } from '../../../../rules_client/common'; import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events'; -import { RulesClientContext, NormalizedAlertAction } from '../../../../rules_client/types'; +import { RulesClientContext } from '../../../../rules_client/types'; import { RuleDomain, RuleParams } from '../../types'; import { SanitizedRule } from '../../../../types'; import { @@ -55,14 +56,18 @@ export async function createRule( // TODO (http-versioning): This should be of type Rule, change this when all rule types are fixed ): Promise> { const { data: initialData, options, allowMissingConnectorSecrets } = createParams; + const actionsClient = await context.getActionsClient(); + + const { actions: genAction, systemActions: genSystemActions } = await addGeneratedActionValues( + initialData.actions, + initialData.systemActions, + context + ); - // TODO (http-versioning): Remove this cast when we fix addGeneratedActionValues const data = { ...initialData, - actions: await addGeneratedActionValues( - initialData.actions as NormalizedAlertAction[], - context - ), + actions: genAction, + systemActions: genSystemActions, }; const id = options?.id || SavedObjectsUtils.generateId(); @@ -144,6 +149,14 @@ export async function createRule( validateActions(context, ruleType, data, allowMissingConnectorSecrets) ); + await withSpan({ name: 'validateSystemActions', type: 'rules' }, () => + validateSystemActions({ + actionsClient, + connectorAdapterRegistry: context.connectorAdapterRegistry, + systemActions: data.systemActions, + }) + ); + // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); if ( @@ -155,13 +168,14 @@ export async function createRule( ); } + const allActions = [...data.actions, ...(data.systemActions ?? [])]; // Extract saved object references for this rule const { references, params: updatedParams, - actions, + actions: actionsWithRefs, } = await withSpan({ name: 'extractReferences', type: 'rules' }, () => - extractReferences(context, ruleType, data.actions, validatedRuleTypeParams) + extractReferences(context, ruleType, allActions, validatedRuleTypeParams) ); const createTime = Date.now(); @@ -170,10 +184,12 @@ export async function createRule( const notifyWhen = getRuleNotifyWhenType(data.notifyWhen ?? null, data.throttle ?? null); const throttle = data.throttle ?? null; + const { systemActions, actions: actionToNotUse, ...restData } = data; // Convert domain rule object to ES rule attributes - const ruleAttributes = transformRuleDomainToRuleAttributes( - { - ...data, + const ruleAttributes = transformRuleDomainToRuleAttributes({ + actionsWithRefs, + rule: { + ...restData, // TODO (http-versioning) create a rule domain version of this function // Right now this works because the 2 types can interop but it's not ideal ...apiKeyAsRuleDomainProperties(createdAPIKey, username, isAuthTypeApiKey), @@ -192,13 +208,12 @@ export async function createRule( revision: 0, running: false, }, - { + params: { legacyId, - actionsWithRefs: actions, // @ts-expect-error upgrade typescript v4.9.5 paramsWithRefs: updatedParams, - } - ); + }, + }); const createdRuleSavedObject: SavedObject = await withSpan( { name: 'createRuleSavedObject', type: 'rules' }, @@ -221,7 +236,8 @@ export async function createRule( logger: context.logger, ruleType: context.ruleTypeRegistry.get(createdRuleSavedObject.attributes.alertTypeId), references, - } + }, + (connectorId: string) => actionsClient.isSystemAction(connectorId) ); // Try to validate created rule, but don't throw. diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts index 9d1823381d17d..6538042dd4297 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts @@ -9,6 +9,30 @@ import { schema } from '@kbn/config-schema'; import { validateDuration } from '../../../validation'; import { notifyWhenSchema, actionAlertsFilterSchema, alertDelaySchema } from '../../../schemas'; +export const defaultActionSchema = schema.object({ + group: schema.string(), + id: schema.string(), + actionTypeId: schema.maybe(schema.string()), + params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }), + frequency: schema.maybe( + schema.object({ + summary: schema.boolean(), + notifyWhen: notifyWhenSchema, + throttle: schema.nullable(schema.string({ validate: validateDuration })), + }) + ), + uuid: schema.maybe(schema.string()), + alertsFilter: schema.maybe(actionAlertsFilterSchema), + useAlertDataForTemplate: schema.maybe(schema.boolean()), +}); + +export const systemActionSchema = schema.object({ + id: schema.string(), + actionTypeId: schema.maybe(schema.string()), + params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }), + uuid: schema.maybe(schema.string()), +}); + export const createRuleDataSchema = schema.object({ name: schema.string(), alertTypeId: schema.string(), @@ -20,24 +44,13 @@ export const createRuleDataSchema = schema.object({ schedule: schema.object({ interval: schema.string({ validate: validateDuration }), }), - actions: schema.arrayOf( - schema.object({ - group: schema.string(), - id: schema.string(), - actionTypeId: schema.maybe(schema.string()), - params: schema.recordOf(schema.string(), schema.maybe(schema.any()), { defaultValue: {} }), - frequency: schema.maybe( - schema.object({ - summary: schema.boolean(), - notifyWhen: notifyWhenSchema, - throttle: schema.nullable(schema.string({ validate: validateDuration })), - }) - ), - uuid: schema.maybe(schema.string()), - alertsFilter: schema.maybe(actionAlertsFilterSchema), - useAlertDataForTemplate: schema.maybe(schema.boolean()), - }), - { defaultValue: [] } + actions: schema.arrayOf(defaultActionSchema, { + defaultValue: [], + }), + systemActions: schema.maybe( + schema.arrayOf(systemActionSchema, { + defaultValue: [], + }) ), notifyWhen: schema.maybe(schema.nullable(notifyWhenSchema)), alertDelay: schema.maybe(alertDelaySchema), diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/index.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/index.ts index 3c9154dcac7f2..9c0e20860bdc6 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/index.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/index.ts @@ -5,4 +5,8 @@ * 2.0. */ -export { createRuleDataSchema } from './create_rule_data_schema'; +export { + createRuleDataSchema, + defaultActionSchema, + systemActionSchema, +} from './create_rule_data_schema'; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/types/create_rule_data.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/types/create_rule_data.ts index abee30ec9a524..be9f40de388f2 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/types/create_rule_data.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/types/create_rule_data.ts @@ -6,10 +6,12 @@ */ import { TypeOf } from '@kbn/config-schema'; -import { createRuleDataSchema } from '../schemas'; +import { createRuleDataSchema, defaultActionSchema, systemActionSchema } from '../schemas'; import { RuleParams } from '../../../types'; type CreateRuleDataType = TypeOf; +type CreateRuleActionDataType = TypeOf; +type CreateRuleSystemActionDataType = TypeOf; export interface CreateRuleData { name: CreateRuleDataType['name']; @@ -20,7 +22,8 @@ export interface CreateRuleData { throttle?: CreateRuleDataType['throttle']; params: Params; schedule: CreateRuleDataType['schedule']; - actions: CreateRuleDataType['actions']; + actions: CreateRuleActionDataType[]; + systemActions?: CreateRuleSystemActionDataType[]; notifyWhen?: CreateRuleDataType['notifyWhen']; alertDelay?: CreateRuleDataType['alertDelay']; } diff --git a/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts index 03f23256041b6..a0b44abde3c54 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts @@ -21,6 +21,7 @@ import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { AlertingAuthorization } from '../../../../authorization/alerting_authorization'; import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -54,9 +55,11 @@ const rulesClientParams: jest.Mocked = { minimumScheduleInterval: { value: '1m', enforce: false }, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; const getMockAggregationResult = ( diff --git a/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve.test.ts new file mode 100644 index 0000000000000..b611f5f2d3ef5 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve.test.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConstructorOptions, RulesClient } from '../../../../rules_client/rules_client'; +import { + savedObjectsClientMock, + loggingSystemMock, + savedObjectsRepositoryMock, + uiSettingsServiceMock, +} from '@kbn/core/server/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { AlertingAuthorization } from '../../../../authorization/alerting_authorization'; +import { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { getBeforeSetup } from '../../../../rules_client/tests/lib'; + +describe('resolve', () => { + const taskManager = taskManagerMock.createStart(); + const ruleTypeRegistry = ruleTypeRegistryMock.create(); + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); + const authorization = alertingAuthorizationMock.create(); + const actionsAuthorization = actionsAuthorizationMock.create(); + const auditLogger = auditLoggerMock.create(); + const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + + const kibanaVersion = 'v8.2.0'; + const createAPIKeyMock = jest.fn(); + const isAuthenticationTypeApiKeyMock = jest.fn(); + const getAuthenticationApiKeyMock = jest.fn(); + + const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: createAPIKeyMock, + logger: loggingSystemMock.create().get(), + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: isAuthenticationTypeApiKeyMock, + getAuthenticationAPIKey: getAuthenticationApiKeyMock, + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + uiSettings: uiSettingsServiceMock.createStartContract(), + }; + + let rulesClient: RulesClient; + + beforeEach(() => { + jest.clearAllMocks(); + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); + rulesClient = new RulesClient(rulesClientParams); + }); + + describe('actions', () => { + it('transform actions correctly', async () => { + unsecuredSavedObjectsClient.resolve.mockResolvedValue({ + outcome: 'exactMatch', + saved_object: { + id: 'test-rule', + type: RULE_SAVED_OBJECT_TYPE, + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + params: {}, + actionRef: 'action_0', + actionTypeId: 'test-1', + uuid: '222', + }, + { + params: {}, + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + uuid: '222', + }, + ], + notifyWhen: 'onActiveAlert', + executionStatus: {}, + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + }); + + const res = await rulesClient.resolve({ id: 'test-rule' }); + + expect(res.actions).toEqual([ + { + actionTypeId: 'test-1', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + group: 'default', + id: '1', + params: {}, + uuid: '222', + }, + ]); + + expect(res.systemActions).toEqual([ + { actionTypeId: 'test-2', id: 'system_action-id', params: {}, uuid: '222' }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve_rule.ts b/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve_rule.ts index de9f421a95924..359f3189958c5 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve_rule.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve_rule.ts @@ -66,13 +66,17 @@ Promise> { }) ); - const ruleDomain = transformRuleAttributesToRuleDomain(result.attributes, { - id: result.id, - logger: context.logger, - ruleType: context.ruleTypeRegistry.get(result.attributes.alertTypeId), - references: result.references, - includeSnoozeData, - }); + const ruleDomain = transformRuleAttributesToRuleDomain( + result.attributes, + { + id: result.id, + logger: context.logger, + ruleType: context.ruleTypeRegistry.get(result.attributes.alertTypeId), + references: result.references, + includeSnoozeData, + }, + context.isSystemAction + ); const rule = transformRuleDomainToRule(ruleDomain); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/tags/get_rule_tags.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/tags/get_rule_tags.test.ts index 6ae8285d7fa30..e6c99f779909d 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/tags/get_rule_tags.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/tags/get_rule_tags.test.ts @@ -23,6 +23,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup } from '../../../../rules_client/tests/lib'; import { RecoveredActionGroup } from '../../../../../common'; import { RegistryRuleType } from '../../../../rule_type_registry'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; const taskManager = taskManagerMock.createStart(); @@ -56,9 +57,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; const listedTypes = new Set([ diff --git a/x-pack/plugins/alerting/server/application/rule/schemas/action_schemas.ts b/x-pack/plugins/alerting/server/application/rule/schemas/action_schemas.ts index 7cbadb6199081..fa60f230d6ab7 100644 --- a/x-pack/plugins/alerting/server/application/rule/schemas/action_schemas.ts +++ b/x-pack/plugins/alerting/server/application/rule/schemas/action_schemas.ts @@ -44,7 +44,7 @@ const actionFrequencySchema = schema.object({ /** * Unsanitized (domain) action schema, used by internal rules clients */ -export const actionDomainSchema = schema.object({ +export const defaultActionDomainSchema = schema.object({ uuid: schema.maybe(schema.string()), group: schema.string(), id: schema.string(), @@ -52,7 +52,14 @@ export const actionDomainSchema = schema.object({ params: actionParamsSchema, frequency: schema.maybe(actionFrequencySchema), alertsFilter: schema.maybe(actionDomainAlertsFilterSchema), - useAlertDataAsTemplate: schema.maybe(schema.boolean()), + useAlertDataForTemplate: schema.maybe(schema.boolean()), +}); + +export const systemActionDomainSchema = schema.object({ + id: schema.string(), + actionTypeId: schema.string(), + params: actionParamsSchema, + uuid: schema.maybe(schema.string()), }); export const actionAlertsFilterSchema = schema.object({ @@ -60,7 +67,7 @@ export const actionAlertsFilterSchema = schema.object({ timeframe: schema.maybe(actionAlertsFilterTimeFrameSchema), }); -export const actionSchema = schema.object({ +export const defaultActionSchema = schema.object({ uuid: schema.maybe(schema.string()), group: schema.string(), id: schema.string(), @@ -70,3 +77,11 @@ export const actionSchema = schema.object({ alertsFilter: schema.maybe(actionAlertsFilterSchema), useAlertDataForTemplate: schema.maybe(schema.boolean()), }); + +export const systemActionSchema = schema.object({ + id: schema.string(), + actionTypeId: schema.string(), + params: actionParamsSchema, + uuid: schema.maybe(schema.string()), + useAlertDataAsTemplate: schema.maybe(schema.boolean()), +}); diff --git a/x-pack/plugins/alerting/server/application/rule/schemas/index.ts b/x-pack/plugins/alerting/server/application/rule/schemas/index.ts index 06645e90d7baf..1c77fc9e43ee6 100644 --- a/x-pack/plugins/alerting/server/application/rule/schemas/index.ts +++ b/x-pack/plugins/alerting/server/application/rule/schemas/index.ts @@ -18,8 +18,8 @@ export { export { actionParamsSchema, - actionDomainSchema, - actionSchema, + defaultActionDomainSchema, + systemActionDomainSchema, actionAlertsFilterSchema, } from './action_schemas'; diff --git a/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts b/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts index 6041e475daf52..94676e9717052 100644 --- a/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts +++ b/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts @@ -15,7 +15,12 @@ import { import { rRuleSchema } from '../../r_rule/schemas'; import { dateSchema } from './date_schema'; import { notifyWhenSchema } from './notify_when_schema'; -import { actionDomainSchema, actionSchema } from './action_schemas'; +import { + defaultActionDomainSchema, + defaultActionSchema, + systemActionDomainSchema, + systemActionSchema, +} from './action_schemas'; export const ruleParamsSchema = schema.recordOf(schema.string(), schema.maybe(schema.any())); export const mappedParamsSchema = schema.recordOf(schema.string(), schema.maybe(schema.any())); @@ -147,7 +152,8 @@ export const ruleDomainSchema = schema.object({ alertTypeId: schema.string(), consumer: schema.string(), schedule: intervalScheduleSchema, - actions: schema.arrayOf(actionDomainSchema), + actions: schema.arrayOf(defaultActionDomainSchema), + systemActions: schema.maybe(schema.arrayOf(systemActionDomainSchema)), params: ruleParamsSchema, mapped_params: schema.maybe(mappedParamsSchema), scheduledTaskId: schema.maybe(schema.string()), @@ -186,7 +192,8 @@ export const ruleSchema = schema.object({ alertTypeId: schema.string(), consumer: schema.string(), schedule: intervalScheduleSchema, - actions: schema.arrayOf(actionSchema), + actions: schema.arrayOf(defaultActionSchema), + systemActions: schema.maybe(schema.arrayOf(systemActionSchema)), params: ruleParamsSchema, mapped_params: schema.maybe(mappedParamsSchema), scheduledTaskId: schema.maybe(schema.string()), diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/index.ts b/x-pack/plugins/alerting/server/application/rule/transforms/index.ts index 69f2e1dc36844..d3933385ae898 100644 --- a/x-pack/plugins/alerting/server/application/rule/transforms/index.ts +++ b/x-pack/plugins/alerting/server/application/rule/transforms/index.ts @@ -8,3 +8,4 @@ export { transformRuleAttributesToRuleDomain } from './transform_rule_attributes_to_rule_domain'; export { transformRuleDomainToRuleAttributes } from './transform_rule_domain_to_rule_attributes'; export { transformRuleDomainToRule } from './transform_rule_domain_to_rule'; +export { transformRawActionsToDomainActions } from './transform_raw_actions_to_domain_actions'; diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.test.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.test.ts new file mode 100644 index 0000000000000..591518e9b13ee --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RuleActionAttributes } from '../../../data/rule/types'; +import { + transformRawActionsToDomainActions, + transformRawActionsToDomainSystemActions, +} from './transform_raw_actions_to_domain_actions'; + +const defaultAction: RuleActionAttributes = { + group: 'default', + uuid: '1', + actionRef: 'default-action-ref', + actionTypeId: '.test', + params: {}, + frequency: { + summary: false, + notifyWhen: 'onThrottleInterval', + throttle: '1m', + }, + alertsFilter: { query: { kql: 'test:1', dsl: '{}', filters: [] } }, +}; + +const systemAction: RuleActionAttributes = { + actionRef: 'system_action:my-system-action-id', + uuid: '123', + actionTypeId: '.test-system-action', + params: {}, +}; + +const isSystemAction = (id: string) => id === 'my-system-action-id'; + +describe('transformRawActionsToDomainActions', () => { + it('transforms the actions correctly', () => { + const res = transformRawActionsToDomainActions({ + actions: [defaultAction, systemAction], + ruleId: 'test-rule', + references: [{ name: 'default-action-ref', id: 'default-action-id', type: 'action' }], + isSystemAction, + }); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": ".test", + "alertsFilter": Object { + "query": Object { + "filters": Array [], + "kql": "test:1", + }, + }, + "frequency": Object { + "notifyWhen": "onThrottleInterval", + "summary": false, + "throttle": "1m", + }, + "group": "default", + "id": "default-action-id", + "params": Object {}, + "uuid": "1", + }, + ] + `); + }); +}); + +describe('transformRawActionsToDomainSystemActions', () => { + it('transforms the system actions correctly', () => { + const res = transformRawActionsToDomainSystemActions({ + actions: [defaultAction, systemAction], + ruleId: 'test-rule', + references: [{ name: 'default-action-ref', id: 'default-action-id', type: 'action' }], + isSystemAction, + }); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": ".test-system-action", + "id": "my-system-action-id", + "params": Object {}, + "uuid": "123", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.ts new file mode 100644 index 0000000000000..14f376c7d8176 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_raw_actions_to_domain_actions.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omit } from 'lodash'; +import { SavedObjectReference } from '@kbn/core/server'; +import { injectReferencesIntoActions } from '../../../rules_client/common'; +import { RuleAttributes } from '../../../data/rule/types'; +import { RawRule } from '../../../types'; +import { RuleDomain } from '../types'; + +interface Args { + ruleId: string; + actions: RuleAttributes['actions'] | RawRule['actions']; + isSystemAction: (connectorId: string) => boolean; + omitGeneratedValues?: boolean; + references?: SavedObjectReference[]; +} + +export const transformRawActionsToDomainActions = ({ + actions, + ruleId, + references, + omitGeneratedValues = true, + isSystemAction, +}: Args): RuleDomain['actions'] => { + const actionsWithInjectedRefs = actions + ? injectReferencesIntoActions(ruleId, actions, references || []) + : []; + + const ruleDomainActions = actionsWithInjectedRefs + .filter((action) => !isSystemAction(action.id)) + .map((action) => { + const defaultAction = { + group: action.group ?? 'default', + id: action.id, + params: action.params, + actionTypeId: action.actionTypeId, + uuid: action.uuid, + ...(action.frequency ? { frequency: action.frequency } : {}), + ...(action.alertsFilter ? { alertsFilter: action.alertsFilter } : {}), + ...(action.useAlertDataAsTemplate + ? { useAlertDataAsTemplate: action.useAlertDataAsTemplate } + : {}), + }; + + if (omitGeneratedValues) { + return omit(defaultAction, 'alertsFilter.query.dsl'); + } + + return defaultAction; + }); + + return ruleDomainActions; +}; + +export const transformRawActionsToDomainSystemActions = ({ + actions, + ruleId, + references, + omitGeneratedValues = true, + isSystemAction, +}: Args): RuleDomain['systemActions'] => { + const actionsWithInjectedRefs = actions + ? injectReferencesIntoActions(ruleId, actions, references || []) + : []; + + const ruleDomainSystemActions = actionsWithInjectedRefs + .filter((action) => isSystemAction(action.id)) + .map((action) => { + return { + id: action.id, + params: action.params, + actionTypeId: action.actionTypeId, + uuid: action.uuid, + ...(action.useAlertDataAsTemplate + ? { useAlertDataAsTemplate: action.useAlertDataAsTemplate } + : {}), + }; + }); + + return ruleDomainSystemActions; +}; diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.test.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.test.ts new file mode 100644 index 0000000000000..7b6fcb18ae219 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RecoveredActionGroup } from '../../../../common'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { transformRuleAttributesToRuleDomain } from './transform_rule_attributes_to_rule_domain'; +import { UntypedNormalizedRuleType } from '../../../rule_type_registry'; +import { RuleActionAttributes } from '../../../data/rule/types'; + +const ruleType: jest.Mocked = { + id: 'test.rule-type', + name: 'My test rule', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', + autoRecoverAlerts: true, + doesSetRecoveryContext: true, + validate: { + params: { validate: (params) => params }, + }, + alerts: { + context: 'test', + mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: true, + }, + category: 'test', + validLegacyConsumers: [], +}; + +const defaultAction: RuleActionAttributes = { + group: 'default', + uuid: '1', + actionRef: 'default-action-ref', + actionTypeId: '.test', + params: {}, + frequency: { + summary: false, + notifyWhen: 'onThrottleInterval', + throttle: '1m', + }, + alertsFilter: { query: { kql: 'test:1', dsl: '{}', filters: [] } }, +}; + +const systemAction: RuleActionAttributes = { + actionRef: 'system_action:my-system-action-id', + uuid: '123', + actionTypeId: '.test-system-action', + params: {}, +}; + +const isSystemAction = (id: string) => id === 'my-system-action-id'; + +describe('transformRuleAttributesToRuleDomain', () => { + const MOCK_API_KEY = Buffer.from('123:abc').toString('base64'); + const logger = loggingSystemMock.create().get(); + const references = [{ name: 'default-action-ref', type: 'action', id: 'default-action-id' }]; + + const rule = { + enabled: false, + tags: ['foo'], + createdBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + legacyId: null, + muteAll: false, + mutedInstanceIds: [], + snoozeSchedule: [], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending' as const, + }, + params: {}, + throttle: null, + notifyWhen: null, + actions: [defaultAction, systemAction], + name: 'my rule name', + revision: 0, + updatedBy: 'user', + apiKey: MOCK_API_KEY, + apiKeyOwner: 'user', + }; + + it('transforms the actions correctly', () => { + const res = transformRuleAttributesToRuleDomain( + rule, + { + id: '1', + logger, + ruleType, + references, + }, + isSystemAction + ); + + expect(res.actions).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": ".test", + "alertsFilter": Object { + "query": Object { + "filters": Array [], + "kql": "test:1", + }, + }, + "frequency": Object { + "notifyWhen": "onThrottleInterval", + "summary": false, + "throttle": "1m", + }, + "group": "default", + "id": "default-action-id", + "params": Object {}, + "uuid": "1", + }, + ] + `); + expect(res.systemActions).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": ".test-system-action", + "id": "my-system-action-id", + "params": Object {}, + "uuid": "123", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts index 9cc8131b1fa2d..41e251ff47c3e 100644 --- a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts @@ -4,20 +4,21 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { omit, isEmpty } from 'lodash'; +import { isEmpty } from 'lodash'; import { Logger } from '@kbn/core/server'; import { SavedObjectReference } from '@kbn/core/server'; import { ruleExecutionStatusValues } from '../constants'; import { getRuleSnoozeEndTime } from '../../../lib'; import { RuleDomain, Monitoring, RuleParams } from '../types'; import { RuleAttributes } from '../../../data/rule/types'; -import { RawRule, PartialRule } from '../../../types'; +import { PartialRule } from '../../../types'; import { UntypedNormalizedRuleType } from '../../../rule_type_registry'; -import { - injectReferencesIntoActions, - injectReferencesIntoParams, -} from '../../../rules_client/common'; +import { injectReferencesIntoParams } from '../../../rules_client/common'; import { getActiveScheduledSnoozes } from '../../../lib/is_rule_snoozed'; +import { + transformRawActionsToDomainActions, + transformRawActionsToDomainSystemActions, +} from './transform_raw_actions_to_domain_actions'; const INITIAL_LAST_RUN_METRICS = { duration: 0, @@ -120,7 +121,8 @@ interface TransformEsToRuleParams { export const transformRuleAttributesToRuleDomain = ( esRule: RuleAttributes, - transformParams: TransformEsToRuleParams + transformParams: TransformEsToRuleParams, + isSystemAction: (connectorId: string) => boolean ): RuleDomain => { const { scheduledTaskId, executionStatus, monitoring, snoozeSchedule, lastRun } = esRule; @@ -141,6 +143,7 @@ export const transformRuleAttributesToRuleDomain = omit(ruleAction, 'alertsFilter.query.dsl')); - } + const ruleDomainActions: RuleDomain['actions'] = transformRawActionsToDomainActions({ + ruleId: id, + actions: esRule.actions, + references, + isSystemAction, + omitGeneratedValues, + }); + const ruleDomainSystemActions: RuleDomain['systemActions'] = + transformRawActionsToDomainSystemActions({ + ruleId: id, + actions: esRule.actions, + references, + isSystemAction, + omitGeneratedValues, + }); const params = injectReferencesIntoParams( id, @@ -177,7 +188,8 @@ export const transformRuleAttributesToRuleDomain = ( consumer: ruleDomain.consumer, schedule: ruleDomain.schedule, actions: ruleDomain.actions, + systemActions: ruleDomain.systemActions, params: ruleDomain.params, mapped_params: ruleDomain.mapped_params, scheduledTaskId: ruleDomain.scheduledTaskId, diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts index e527bd6f31df0..0e70525981496 100644 --- a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts @@ -7,19 +7,24 @@ import { RuleDomain } from '../types'; import { RuleAttributes } from '../../../data/rule/types'; import { getMappedParams } from '../../../rules_client/common'; +import { DenormalizedAction } from '../../../rules_client'; interface TransformRuleToEsParams { legacyId: RuleAttributes['legacyId']; - actionsWithRefs: RuleAttributes['actions']; paramsWithRefs: RuleAttributes['params']; meta?: RuleAttributes['meta']; } -export const transformRuleDomainToRuleAttributes = ( - rule: Omit, - params: TransformRuleToEsParams -): RuleAttributes => { - const { legacyId, actionsWithRefs, paramsWithRefs, meta } = params; +export const transformRuleDomainToRuleAttributes = ({ + actionsWithRefs, + rule, + params, +}: { + actionsWithRefs: DenormalizedAction[]; + rule: Omit; + params: TransformRuleToEsParams; +}): RuleAttributes => { + const { legacyId, paramsWithRefs, meta } = params; const mappedParams = getMappedParams(paramsWithRefs); return { diff --git a/x-pack/plugins/alerting/server/application/rule/types/rule.ts b/x-pack/plugins/alerting/server/application/rule/types/rule.ts index f59056b382440..16b4a079862eb 100644 --- a/x-pack/plugins/alerting/server/application/rule/types/rule.ts +++ b/x-pack/plugins/alerting/server/application/rule/types/rule.ts @@ -12,7 +12,6 @@ import { ruleExecutionStatusValues, ruleExecutionStatusErrorReason, ruleExecutionStatusWarningReason, - filterStateStore, } from '../constants'; import { ruleParamsSchema, @@ -20,7 +19,6 @@ import { ruleExecutionStatusSchema, ruleLastRunSchema, monitoringSchema, - actionSchema, ruleSchema, ruleDomainSchema, } from '../schemas'; @@ -34,13 +32,11 @@ export type RuleExecutionStatusErrorReason = typeof ruleExecutionStatusErrorReason[keyof typeof ruleExecutionStatusErrorReason]; export type RuleExecutionStatusWarningReason = typeof ruleExecutionStatusWarningReason[keyof typeof ruleExecutionStatusWarningReason]; -export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore]; export type RuleParams = TypeOf; export type RuleSnoozeSchedule = TypeOf; export type RuleLastRun = TypeOf; export type Monitoring = TypeOf; -export type Action = TypeOf; type RuleSchemaType = TypeOf; type RuleDomainSchemaType = TypeOf; @@ -62,6 +58,7 @@ export interface Rule { consumer: RuleSchemaType['consumer']; schedule: RuleSchemaType['schedule']; actions: RuleSchemaType['actions']; + systemActions?: RuleSchemaType['systemActions']; params: Params; mapped_params?: RuleSchemaType['mapped_params']; scheduledTaskId?: RuleSchemaType['scheduledTaskId']; @@ -97,6 +94,7 @@ export interface RuleDomain { consumer: RuleDomainSchemaType['consumer']; schedule: RuleDomainSchemaType['schedule']; actions: RuleDomainSchemaType['actions']; + systemActions: RuleDomainSchemaType['systemActions']; params: Params; mapped_params?: RuleDomainSchemaType['mapped_params']; scheduledTaskId?: RuleDomainSchemaType['scheduledTaskId']; diff --git a/x-pack/plugins/alerting/server/connector_adapters/connector_adapter_registry.test.ts b/x-pack/plugins/alerting/server/connector_adapters/connector_adapter_registry.test.ts new file mode 100644 index 0000000000000..2798a55268f9b --- /dev/null +++ b/x-pack/plugins/alerting/server/connector_adapters/connector_adapter_registry.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { ConnectorAdapterRegistry } from './connector_adapter_registry'; +import type { ConnectorAdapter } from './types'; + +describe('ConnectorAdapterRegistry', () => { + const connectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test', + ruleActionParamsSchema: schema.object({}), + buildActionParams: jest.fn(), + }; + + let registry: ConnectorAdapterRegistry; + + beforeEach(() => { + registry = new ConnectorAdapterRegistry(); + }); + + describe('has', () => { + it('returns true if the connector adapter is registered', () => { + registry.register(connectorAdapter); + expect(registry.has('.test')).toBe(true); + }); + + it('returns false if the connector adapter is not registered', () => { + expect(registry.has('.not-exist')).toBe(false); + }); + }); + + describe('register', () => { + it('registers a connector adapter correctly', () => { + registry.register(connectorAdapter); + expect(registry.get('.test')).toEqual(connectorAdapter); + }); + + it('throws an error if the connector adapter exists', () => { + registry.register(connectorAdapter); + + expect(() => registry.register(connectorAdapter)).toThrowErrorMatchingInlineSnapshot( + `".test is already registered to the ConnectorAdapterRegistry"` + ); + }); + }); + + describe('get', () => { + it('gets a connector adapter correctly', () => { + registry.register(connectorAdapter); + expect(registry.get('.test')).toEqual(connectorAdapter); + }); + + it('throws an error if the connector adapter does not exists', () => { + expect(() => registry.get('.not-exists')).toThrowErrorMatchingInlineSnapshot( + `"Connector adapter \\".not-exists\\" is not registered."` + ); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/connector_adapters/connector_adapter_registry.ts b/x-pack/plugins/alerting/server/connector_adapters/connector_adapter_registry.ts new file mode 100644 index 0000000000000..4558ca5a2d3ee --- /dev/null +++ b/x-pack/plugins/alerting/server/connector_adapters/connector_adapter_registry.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { i18n } from '@kbn/i18n'; + +import { ConnectorAdapter, ConnectorAdapterParams } from './types'; + +export class ConnectorAdapterRegistry { + private readonly connectorAdapters: Map = new Map(); + + public has(connectorTypeId: string): boolean { + return this.connectorAdapters.has(connectorTypeId); + } + + public register< + RuleActionParams extends ConnectorAdapterParams = ConnectorAdapterParams, + ConnectorParams extends ConnectorAdapterParams = ConnectorAdapterParams + >(connectorAdapter: ConnectorAdapter) { + if (this.has(connectorAdapter.connectorTypeId)) { + throw new Error( + `${connectorAdapter.connectorTypeId} is already registered to the ConnectorAdapterRegistry` + ); + } + + this.connectorAdapters.set( + connectorAdapter.connectorTypeId, + connectorAdapter as unknown as ConnectorAdapter + ); + } + + public get(connectorTypeId: string): ConnectorAdapter { + if (!this.connectorAdapters.has(connectorTypeId)) { + throw Boom.badRequest( + i18n.translate( + 'xpack.alerting.connectorAdapterRegistry.get.missingConnectorAdapterErrorMessage', + { + defaultMessage: 'Connector adapter "{connectorTypeId}" is not registered.', + values: { + connectorTypeId, + }, + } + ) + ); + } + + return this.connectorAdapters.get(connectorTypeId)!; + } +} diff --git a/x-pack/plugins/alerting/server/connector_adapters/types.ts b/x-pack/plugins/alerting/server/connector_adapters/types.ts new file mode 100644 index 0000000000000..e2bd2d7ed2c93 --- /dev/null +++ b/x-pack/plugins/alerting/server/connector_adapters/types.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ObjectType } from '@kbn/config-schema'; +import type { RuleTypeParams, SanitizedRule } from '../../common'; +import { CombinedSummarizedAlerts } from '../types'; + +type Rule = Pick, 'id' | 'name' | 'tags'>; + +export interface ConnectorAdapterParams { + [x: string]: unknown; +} + +interface BuildActionParamsArgs { + alerts: CombinedSummarizedAlerts; + rule: Rule; + params: RuleActionParams; + spaceId: string; + ruleUrl?: string; +} + +export interface ConnectorAdapter< + RuleActionParams extends ConnectorAdapterParams = ConnectorAdapterParams, + ConnectorParams extends ConnectorAdapterParams = ConnectorAdapterParams +> { + connectorTypeId: string; + /** + * The schema of the action persisted + * in the rule. The schema will be validated + * when a rule is created or updated. + * The schema should be backwards compatible + * and should never introduce any breaking + * changes. + */ + ruleActionParamsSchema: ObjectType; + buildActionParams: (args: BuildActionParamsArgs) => ConnectorParams; +} diff --git a/x-pack/plugins/alerting/server/connector_adapters/validate_rule_action_params.test.ts b/x-pack/plugins/alerting/server/connector_adapters/validate_rule_action_params.test.ts new file mode 100644 index 0000000000000..42e7379dc0700 --- /dev/null +++ b/x-pack/plugins/alerting/server/connector_adapters/validate_rule_action_params.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { ConnectorAdapterRegistry } from './connector_adapter_registry'; +import type { ConnectorAdapter } from './types'; +import { + bulkValidateConnectorAdapterActionParams, + validateConnectorAdapterActionParams, +} from './validate_rule_action_params'; + +describe('validateRuleActionParams', () => { + const firstConnectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test', + ruleActionParamsSchema: schema.object({ foo: schema.string() }), + buildActionParams: jest.fn(), + }; + + const secondConnectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test-2', + ruleActionParamsSchema: schema.object({ bar: schema.string() }), + buildActionParams: jest.fn(), + }; + + let registry: ConnectorAdapterRegistry; + + beforeEach(() => { + registry = new ConnectorAdapterRegistry(); + }); + + describe('validateConnectorAdapterActionParams', () => { + it('should validate correctly invalid params', () => { + registry.register(firstConnectorAdapter); + + expect(() => + validateConnectorAdapterActionParams({ + connectorAdapterRegistry: registry, + connectorTypeId: firstConnectorAdapter.connectorTypeId, + params: { foo: 5 }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid system action params. System action type: .test - [foo]: expected value of type [string] but got [number]"` + ); + }); + + it('should not throw if the connectorTypeId is not defined', () => { + registry.register(firstConnectorAdapter); + + expect(() => + validateConnectorAdapterActionParams({ + connectorAdapterRegistry: registry, + params: {}, + }) + ).not.toThrow(); + }); + + it('should not throw if the connector adapter is not registered', () => { + expect(() => + validateConnectorAdapterActionParams({ + connectorAdapterRegistry: registry, + connectorTypeId: firstConnectorAdapter.connectorTypeId, + params: {}, + }) + ).not.toThrow(); + }); + }); + + describe('bulkValidateConnectorAdapterActionParams', () => { + it('should validate correctly invalid params with multiple actions', () => { + const actions = [ + { actionTypeId: firstConnectorAdapter.connectorTypeId, params: { foo: 5 } }, + { actionTypeId: secondConnectorAdapter.connectorTypeId, params: { bar: 'test' } }, + ]; + + registry.register(firstConnectorAdapter); + registry.register(secondConnectorAdapter); + + expect(() => + bulkValidateConnectorAdapterActionParams({ + connectorAdapterRegistry: registry, + actions, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid system action params. System action type: .test - [foo]: expected value of type [string] but got [number]"` + ); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/connector_adapters/validate_rule_action_params.ts b/x-pack/plugins/alerting/server/connector_adapters/validate_rule_action_params.ts new file mode 100644 index 0000000000000..abfd56f0e9079 --- /dev/null +++ b/x-pack/plugins/alerting/server/connector_adapters/validate_rule_action_params.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { ConnectorAdapterRegistry } from './connector_adapter_registry'; + +interface ValidateSchemaArgs { + connectorAdapterRegistry: ConnectorAdapterRegistry; + connectorTypeId?: string; + params: Record; +} + +interface BulkValidateSchemaArgs { + connectorAdapterRegistry: ConnectorAdapterRegistry; + actions: Array<{ actionTypeId: string; params: Record }>; +} + +export const validateConnectorAdapterActionParams = ({ + connectorAdapterRegistry, + connectorTypeId, + params, +}: ValidateSchemaArgs) => { + if (!connectorTypeId) { + return; + } + + if (!connectorAdapterRegistry.has(connectorTypeId)) { + return; + } + + const connectorAdapter = connectorAdapterRegistry.get(connectorTypeId); + const schema = connectorAdapter.ruleActionParamsSchema; + + try { + schema.validate(params); + } catch (error) { + throw Boom.badRequest( + `Invalid system action params. System action type: ${connectorAdapter.connectorTypeId} - ${error.message}` + ); + } +}; + +export const bulkValidateConnectorAdapterActionParams = ({ + connectorAdapterRegistry, + actions, +}: BulkValidateSchemaArgs) => { + for (const action of actions) { + validateConnectorAdapterActionParams({ + connectorAdapterRegistry, + connectorTypeId: action.actionTypeId, + params: action.params, + }); + } +}; diff --git a/x-pack/plugins/alerting/server/data/alerts_filter_query/types/alerts_filter_query_attributes.ts b/x-pack/plugins/alerting/server/data/alerts_filter_query/types/alerts_filter_query_attributes.ts index f740d8070abf2..33742f81daa77 100644 --- a/x-pack/plugins/alerting/server/data/alerts_filter_query/types/alerts_filter_query_attributes.ts +++ b/x-pack/plugins/alerting/server/data/alerts_filter_query/types/alerts_filter_query_attributes.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { FilterStateStore } from '../constants'; +import { FilterStateStore } from '@kbn/es-query'; export interface AlertsFilterAttributes { query?: Record; @@ -18,5 +18,5 @@ export interface AlertsFilterAttributes { export interface AlertsFilterQueryAttributes { kql: string; filters: AlertsFilterAttributes[]; - dsl?: string; + dsl: string; } diff --git a/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts b/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts index e047be1b9cddf..151c71b7d79f4 100644 --- a/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts +++ b/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts @@ -114,14 +114,14 @@ interface AlertsFilterTimeFrameAttributes { }; } -interface AlertsFilterAttributes { +export interface AlertsFilterAttributes { query?: AlertsFilterQueryAttributes; timeframe?: AlertsFilterTimeFrameAttributes; } export interface RuleActionAttributes { uuid: string; - group: string; + group?: string; actionRef: string; actionTypeId: string; params: SavedObjectAttributes; @@ -131,6 +131,7 @@ export interface RuleActionAttributes { throttle: string | null; }; alertsFilter?: AlertsFilterAttributes; + useAlertDataAsTemplate?: boolean; } type MappedParamsAttributes = SavedObjectAttributes & { diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index d27e6976b8b7a..c6746d33df67c 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -72,6 +72,7 @@ export { } from './alerts_service'; export { sanitizeBulkErrorResponse, AlertsClientError } from './alerts_client'; export { getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter'; +export type { ConnectorAdapter } from './connector_adapters/types'; export const plugin = async (initContext: PluginInitializerContext) => { const { AlertingPlugin } = await import('./plugin'); diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts index 60ddb03c7c68a..60636e194e194 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts @@ -55,7 +55,7 @@ interface AlertOpts { maintenanceWindowIds?: string[]; } -interface ActionOpts { +export interface ActionOpts { id: string; typeId: string; alertId?: string; diff --git a/x-pack/plugins/alerting/server/lib/validate_system_actions.test.ts b/x-pack/plugins/alerting/server/lib/validate_system_actions.test.ts new file mode 100644 index 0000000000000..7cc7b4041aa08 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/validate_system_actions.test.ts @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionsClient } from '@kbn/actions-plugin/server'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import { schema } from '@kbn/config-schema'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; +import { ConnectorAdapter } from '../connector_adapters/types'; +import { NormalizedSystemAction } from '../rules_client'; +import { RuleSystemAction } from '../types'; +import { validateSystemActions } from './validate_system_actions'; + +describe('validateSystemActionsWithoutRuleTypeId', () => { + const connectorAdapter: ConnectorAdapter = { + connectorTypeId: '.test', + ruleActionParamsSchema: schema.object({ foo: schema.string() }), + buildActionParams: jest.fn(), + }; + + let registry: ConnectorAdapterRegistry; + let actionsClient: jest.Mocked; + + beforeEach(() => { + registry = new ConnectorAdapterRegistry(); + actionsClient = actionsClientMock.create(); + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: '.test', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + }); + + it('should not validate with empty system actions', async () => { + const res = await validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions: [], + actionsClient, + }); + + expect(res).toBe(undefined); + expect(actionsClient.getBulk).not.toBeCalled(); + expect(actionsClient.isSystemAction).not.toBeCalled(); + }); + + it('should throw an error if the action is not a system action even if it is declared as one', async () => { + const systemActions: RuleSystemAction[] = [ + { + id: 'not-exist', + uuid: '123', + params: { foo: 'test' }, + actionTypeId: '.test', + }, + ]; + + registry.register(connectorAdapter); + + actionsClient.isSystemAction.mockReturnValue(false); + + await expect(() => + validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions, + actionsClient, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Action not-exist is not a system action"`); + }); + + it('should throw an error if the action is system action but is not returned from the actions client (getBulk)', async () => { + const systemActions: RuleSystemAction[] = [ + { + id: 'not-exist', + uuid: '123', + params: { foo: 'test' }, + actionTypeId: '.test', + }, + ]; + + registry.register(connectorAdapter); + + actionsClient.isSystemAction.mockReturnValue(true); + + await expect(() => + validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions, + actionsClient, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Action not-exist is not a system action"`); + }); + + it('should throw an error if the params are not valid', async () => { + const systemActions: RuleSystemAction[] = [ + { + id: 'system_action-id', + uuid: '123', + params: { 'not-exist': 'test' }, + actionTypeId: '.test', + }, + ]; + + registry.register(connectorAdapter); + + actionsClient.isSystemAction.mockReturnValue(true); + + await expect(() => + validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions, + actionsClient, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Invalid system action params. System action type: .test - [foo]: expected value of type [string] but got [undefined]"` + ); + }); + + it('should throw an error if the same system action is being used', async () => { + const systemActions: RuleSystemAction[] = [ + { + id: 'system_action-id', + uuid: '123', + params: { foo: 'test' }, + actionTypeId: '.test', + }, + { + id: 'system_action-id', + uuid: '123', + params: { foo: 'test' }, + actionTypeId: '.test', + }, + ]; + + registry.register(connectorAdapter); + + actionsClient.isSystemAction.mockReturnValue(false); + + await expect(() => + validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions, + actionsClient, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Cannot use the same system action twice"`); + }); + + it('should call getBulk correctly', async () => { + const systemActions: Array = [ + { + id: 'system_action-id', + uuid: '123', + params: { foo: 'test' }, + }, + { + id: 'system_action-id-2', + uuid: '123', + params: { foo: 'test' }, + actionTypeId: '.test', + }, + ]; + + actionsClient.getBulk.mockResolvedValue([ + { + id: 'system_action-id', + actionTypeId: '.test', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + { + id: 'system_action-id-2', + actionTypeId: '.test', + config: {}, + isMissingSecrets: false, + name: 'system action connector 2', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + + registry.register(connectorAdapter); + + actionsClient.isSystemAction.mockReturnValue(true); + + const res = await validateSystemActions({ + connectorAdapterRegistry: registry, + systemActions, + actionsClient, + }); + + expect(res).toBe(undefined); + + expect(actionsClient.getBulk).toBeCalledWith({ + ids: ['system_action-id', 'system_action-id-2'], + throwIfSystemAction: false, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/validate_system_actions.ts b/x-pack/plugins/alerting/server/lib/validate_system_actions.ts new file mode 100644 index 0000000000000..190b695efffd7 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/validate_system_actions.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { ActionsClient } from '@kbn/actions-plugin/server'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; +import { bulkValidateConnectorAdapterActionParams } from '../connector_adapters/validate_rule_action_params'; +import { NormalizedSystemAction } from '../rules_client'; +import { RuleSystemAction } from '../types'; +interface Params { + actionsClient: ActionsClient; + connectorAdapterRegistry: ConnectorAdapterRegistry; + systemActions: Array; +} + +export const validateSystemActions = async ({ + actionsClient, + connectorAdapterRegistry, + systemActions = [], +}: Params) => { + if (systemActions.length === 0) { + return; + } + + /** + * When updating or creating a rule the actions may not contain + * the actionTypeId. We need to getBulk using the + * actionsClient to get the actionTypeId of each action. + * The actionTypeId is needed to get the schema of + * the action params using the connector adapter registry + */ + const actionIds: Set = new Set(systemActions.map((action) => action.id)); + + if (actionIds.size !== systemActions.length) { + throw Boom.badRequest('Cannot use the same system action twice'); + } + + const actionResults = await actionsClient.getBulk({ + ids: Array.from(actionIds), + throwIfSystemAction: false, + }); + + const systemActionsWithActionTypeId: RuleSystemAction[] = []; + + for (const systemAction of systemActions) { + const isSystemAction = actionsClient.isSystemAction(systemAction.id); + const foundAction = actionResults.find((actionRes) => actionRes.id === systemAction.id); + + if (!isSystemAction || !foundAction) { + throw Boom.badRequest(`Action ${systemAction.id} is not a system action`); + } + + systemActionsWithActionTypeId.push({ + ...systemAction, + actionTypeId: foundAction.actionTypeId, + }); + } + + bulkValidateConnectorAdapterActionParams({ + connectorAdapterRegistry, + actions: systemActionsWithActionTypeId, + }); +}; diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index ab81e472f938b..1abd11703dd4f 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -36,6 +36,7 @@ const createSetupMock = () => { getContextInitializationPromise: jest.fn(), }, getDataStreamAdapter: jest.fn(), + registerConnectorAdapter: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 010fa5bfab6ea..acbad4a27ffe8 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -27,6 +27,8 @@ import { PluginSetup as DataPluginSetup, } from '@kbn/data-plugin/server'; import { spacesMock } from '@kbn/spaces-plugin/server/mocks'; +import { schema } from '@kbn/config-schema'; +import { serverlessPluginMock } from '@kbn/serverless/server/mocks'; import { AlertsService } from './alerts_service/alerts_service'; import { alertsServiceMock } from './alerts_service/alerts_service.mock'; @@ -37,7 +39,6 @@ jest.mock('./alerts_service/alerts_service', () => ({ import { SharePluginStart } from '@kbn/share-plugin/server'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { generateAlertingConfig } from './test_utils'; -import { serverlessPluginMock } from '@kbn/serverless/server/mocks'; const sampleRuleType: RuleType = { id: 'test', @@ -240,6 +241,32 @@ describe('Alerting Plugin', () => { expect(ruleType.cancelAlertsOnRuleTimeout).toBe(false); }); }); + + describe('registerConnectorAdapter()', () => { + let setup: PluginSetupContract; + + beforeEach(async () => { + const context = coreMock.createPluginInitializerContext( + generateAlertingConfig() + ); + + plugin = new AlertingPlugin(context); + setup = await plugin.setup(setupMocks, mockPlugins); + }); + + it('should register a connector adapter', () => { + const adapter = { + connectorTypeId: '.test', + ruleActionParamsSchema: schema.object({}), + buildActionParams: jest.fn(), + }; + + setup.registerConnectorAdapter(adapter); + + // @ts-expect-error: private properties cannot be accessed + expect(plugin.connectorAdapterRegistry.get('.test')).toEqual(adapter); + }); + }); }); describe('start()', () => { diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index f5850abf5f99f..820670a63e21c 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -99,6 +99,8 @@ import { } from './alerts_service'; import { getRulesSettingsFeature } from './rules_settings_feature'; import { maintenanceWindowFeature } from './maintenance_window_feature'; +import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter_registry'; +import { ConnectorAdapter, ConnectorAdapterParams } from './connector_adapters/types'; import { DataStreamAdapter, getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter'; import { createGetAlertIndicesAliasFn, GetAlertIndicesAlias } from './lib'; @@ -118,6 +120,12 @@ export const LEGACY_EVENT_LOG_ACTIONS = { }; export interface PluginSetupContract { + registerConnectorAdapter< + RuleActionParams extends ConnectorAdapterParams = ConnectorAdapterParams, + ConnectorParams extends ConnectorAdapterParams = ConnectorAdapterParams + >( + adapter: ConnectorAdapter + ): void; registerType< Params extends RuleTypeParams = RuleTypeParams, ExtractedParams extends RuleTypeParams = RuleTypeParams, @@ -216,6 +224,7 @@ export class AlertingPlugin { private pluginStop$: Subject; private dataStreamAdapter?: DataStreamAdapter; private nodeRoles: PluginInitializerContext['node']['roles']; + private readonly connectorAdapterRegistry = new ConnectorAdapterRegistry(); constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.get(); @@ -378,6 +387,14 @@ export class AlertingPlugin { }); return { + registerConnectorAdapter: < + RuleActionParams extends ConnectorAdapterParams = ConnectorAdapterParams, + ConnectorParams extends ConnectorAdapterParams = ConnectorAdapterParams + >( + adapter: ConnectorAdapter + ) => { + this.connectorAdapterRegistry.register(adapter); + }, registerType: < Params extends RuleTypeParams = never, ExtractedParams extends RuleTypeParams = never, @@ -507,6 +524,7 @@ export class AlertingPlugin { maxScheduledPerMinute: this.config.rules.maxScheduledPerMinute, getAlertIndicesAlias: createGetAlertIndicesAliasFn(this.ruleTypeRegistry!), alertsService: this.alertsService, + connectorAdapterRegistry: this.connectorAdapterRegistry, uiSettings: core.uiSettings, }); @@ -573,6 +591,7 @@ export class AlertingPlugin { usageCounter: this.usageCounter, getRulesSettingsClientWithRequest, getMaintenanceWindowClientWithRequest, + connectorAdapterRegistry: this.connectorAdapterRegistry, }); this.eventLogService!.registerSavedObjectProvider(RULE_SAVED_OBJECT_TYPE, (request) => { diff --git a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts index 8673614ed2ec0..cab10e242cdad 100644 --- a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts +++ b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts @@ -9,6 +9,8 @@ import { KibanaRequest, KibanaResponseFactory } from '@kbn/core/server'; import { identity } from 'lodash'; import type { MethodKeysOf } from '@kbn/utility-types'; import { httpServerMock } from '@kbn/core/server/mocks'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import type { ActionsClientMock } from '@kbn/actions-plugin/server/mocks'; import { rulesClientMock, RulesClientMock } from '../rules_client.mock'; import { rulesSettingsClientMock, RulesSettingsClientMock } from '../rules_settings_client.mock'; import { @@ -21,6 +23,7 @@ import type { AlertingRequestHandlerContext } from '../types'; export function mockHandlerArguments( { rulesClient = rulesClientMock.create(), + actionsClient = actionsClientMock.create(), rulesSettingsClient = rulesSettingsClientMock.create(), maintenanceWindowClient = maintenanceWindowClientMock.create(), listTypes: listTypesRes = [], @@ -28,6 +31,7 @@ export function mockHandlerArguments( areApiKeysEnabled, }: { rulesClient?: RulesClientMock; + actionsClient?: ActionsClientMock; rulesSettingsClient?: RulesSettingsClientMock; maintenanceWindowClient?: MaintenanceWindowClientMock; listTypes?: RuleType[]; @@ -43,6 +47,10 @@ export function mockHandlerArguments( KibanaResponseFactory ] { const listTypes = jest.fn(() => listTypesRes); + const actionsClientMocked = actionsClient || actionsClientMock.create(); + + actionsClient.isSystemAction.mockImplementation((id) => id === 'system_action-id'); + return [ { alerting: { @@ -59,6 +67,11 @@ export function mockHandlerArguments( getFrameworkHealth, areApiKeysEnabled: areApiKeysEnabled ? areApiKeysEnabled : () => Promise.resolve(true), }, + actions: { + getActionsClient() { + return actionsClientMocked; + }, + }, } as unknown as AlertingRequestHandlerContext, request as KibanaRequest, mockResponseFactory(response), diff --git a/x-pack/plugins/alerting/server/routes/bulk_enable_rules.test.ts b/x-pack/plugins/alerting/server/routes/bulk_enable_rules.test.ts index 367eb0c75ba96..1ede4d1895c4f 100644 --- a/x-pack/plugins/alerting/server/routes/bulk_enable_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/bulk_enable_rules.test.ts @@ -6,13 +6,15 @@ */ import { httpServiceMock } from '@kbn/core/server/mocks'; - +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; import { bulkEnableRulesRoute } from './bulk_enable_rules'; import { licenseStateMock } from '../lib/license_state.mock'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { rulesClientMock } from '../rules_client.mock'; import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled'; import { verifyApiAccess } from '../lib/license_api_access'; +import { RuleAction, RuleSystemAction } from '../types'; +import { Rule } from '../application/rule/types'; const rulesClient = rulesClientMock.create(); @@ -123,4 +125,121 @@ describe('bulkEnableRulesRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + describe('actions', () => { + const mockedRule: Rule<{}> = { + id: '1', + alertTypeId: '1', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + actions: [ + { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '123-456', + }, + ], + consumer: 'bar', + name: 'abc', + tags: ['foo'], + enabled: true, + muteAll: false, + notifyWhen: 'onActionGroupChange', + createdBy: '', + updatedBy: '', + apiKeyOwner: '', + throttle: '30s', + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + revision: 0, + }; + + const action: RuleAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; + + const mockedRules: Array> = [ + { + ...mockedRule, + actions: [action], + systemActions: [systemAction], + }, + ]; + + const bulkEnableActionsResult = { + rules: mockedRules, + errors: [], + total: 1, + taskIdsFailedToBeEnabled: [], + }; + + it('should merge actions and systemActions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + bulkEnableRulesRoute({ router, licenseState }); + const [_, handler] = router.patch.mock.calls[0]; + + rulesClient.bulkEnableRules.mockResolvedValueOnce(bulkEnableActionsResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: bulkEnableRequest, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.rules[0].actions).toEqual([ + { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }, + { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/bulk_enable_rules.ts b/x-pack/plugins/alerting/server/routes/bulk_enable_rules.ts index 50b35c3b16cb3..ddfce5905ca4a 100644 --- a/x-pack/plugins/alerting/server/routes/bulk_enable_rules.ts +++ b/x-pack/plugins/alerting/server/routes/bulk_enable_rules.ts @@ -35,8 +35,19 @@ export const bulkEnableRulesRoute = ({ const { filter, ids } = req.body; try { - const result = await rulesClient.bulkEnableRules({ filter, ids }); - return res.ok({ body: result }); + const bulkEnableResults = await rulesClient.bulkEnableRules({ filter, ids }); + + const resultBody = { + body: { + ...bulkEnableResults, + // TODO We need to fix this API to return snake case like every other API + rules: bulkEnableResults.rules.map(({ actions, systemActions, ...rule }) => { + return { ...rule, actions: [...actions, ...(systemActions ?? [])] }; + }), + }, + }; + + return res.ok(resultBody); } catch (e) { if (e instanceof RuleTypeDisabledError) { return e.sendResponse(res); diff --git a/x-pack/plugins/alerting/server/routes/clone_rule.test.ts b/x-pack/plugins/alerting/server/routes/clone_rule.test.ts index df090abee9bad..a721ce45ad6a7 100644 --- a/x-pack/plugins/alerting/server/routes/clone_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/clone_rule.test.ts @@ -13,8 +13,7 @@ import { mockHandlerArguments } from './_mock_handler_arguments'; import { rulesClientMock } from '../rules_client.mock'; import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled'; import { cloneRuleRoute } from './clone_rule'; -import { SanitizedRule } from '../types'; -import { AsApiContract } from './lib'; +import { RuleAction, RuleSystemAction, SanitizedRule } from '../types'; const rulesClient = rulesClientMock.create(); jest.mock('../lib/license_api_access', () => ({ @@ -29,6 +28,25 @@ describe('cloneRuleRoute', () => { const createdAt = new Date(); const updatedAt = new Date(); + const action: RuleAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; + const mockedRule: SanitizedRule<{ bar: boolean }> = { alertTypeId: '1', consumer: 'bar', @@ -39,17 +57,7 @@ describe('cloneRuleRoute', () => { bar: true, }, throttle: '30s', - actions: [ - { - actionTypeId: 'test', - group: 'default', - id: '2', - params: { - foo: true, - }, - uuid: '123-456', - }, - ], + actions: [action], enabled: true, muteAll: false, createdBy: '', @@ -80,7 +88,7 @@ describe('cloneRuleRoute', () => { ], }; - const cloneResult: AsApiContract> = { + const cloneResult = { ...ruleToClone, mute_all: mockedRule.muteAll, created_by: mockedRule.createdBy, @@ -214,4 +222,54 @@ describe('cloneRuleRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + it('transforms the system actions in the response of the rules client correctly', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + cloneRuleRoute(router, licenseState); + + const [_, handler] = router.post.mock.calls[0]; + + rulesClient.clone.mockResolvedValueOnce({ + ...mockedRule, + actions: [action], + systemActions: [systemAction], + }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.systemActions).toBeUndefined(); + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/clone_rule.ts b/x-pack/plugins/alerting/server/routes/clone_rule.ts index fa775e626d903..b6107a5fc6907 100644 --- a/x-pack/plugins/alerting/server/routes/clone_rule.ts +++ b/x-pack/plugins/alerting/server/routes/clone_rule.ts @@ -8,27 +8,23 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; import { ILicenseState, RuleTypeDisabledError } from '../lib'; -import { - verifyAccessAndContext, - RewriteResponseCase, - handleDisabledApiKeysError, - rewriteRuleLastRun, - rewriteActionsRes, -} from './lib'; +import { verifyAccessAndContext, handleDisabledApiKeysError, rewriteRuleLastRun } from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH, PartialRule, } from '../types'; +import { transformRuleActions } from './rule/transforms'; const paramSchema = schema.object({ id: schema.string(), newId: schema.maybe(schema.string()), }); -const rewriteBodyRes: RewriteResponseCase> = ({ +const rewriteBodyRes = ({ actions, + systemActions, alertTypeId, scheduledTaskId, createdBy, @@ -46,7 +42,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ lastRun, nextRun, ...rest -}) => ({ +}: PartialRule) => ({ ...rest, api_key_owner: apiKeyOwner, created_by: createdBy, @@ -71,7 +67,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ : {}), ...(actions ? { - actions: rewriteActionsRes(actions), + actions: transformRuleActions(actions, systemActions), } : {}), ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), diff --git a/x-pack/plugins/alerting/server/routes/find_rules.test.ts b/x-pack/plugins/alerting/server/routes/find_rules.test.ts index afd621c6e7abd..69df6e978cd83 100644 --- a/x-pack/plugins/alerting/server/routes/find_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/find_rules.test.ts @@ -97,6 +97,219 @@ describe('findRulesRoute', () => { }); }); + it('should rewrite the rule and actions correctly', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + findRulesRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/alerting/rules/_find"`); + + const findResult = { + page: 1, + perPage: 1, + total: 0, + data: [ + { + id: '3d534c70-582b-11ec-8995-2b1578a3bc5d', + notifyWhen: 'onActiveAlert' as const, + alertTypeId: '.index-threshold', + name: 'stressing index-threshold 37/200', + consumer: 'alerts', + tags: [], + enabled: true, + throttle: null, + apiKey: null, + apiKeyOwner: '2889684073', + createdBy: 'elastic', + updatedBy: '2889684073', + muteAll: false, + mutedInstanceIds: [], + schedule: { + interval: '1s', + }, + actions: [ + { + actionTypeId: '.server-log', + params: { + message: 'alert 37: {{context.message}}', + }, + group: 'threshold met', + id: '3619a0d0-582b-11ec-8995-2b1578a3bc5d', + uuid: '123-456', + }, + ], + systemActions: [ + { actionTypeId: '.test', id: 'system_action-id', params: {}, uuid: '789' }, + ], + params: { x: 42 }, + updatedAt: '2024-03-21T13:15:00.498Z', + createdAt: '2024-03-21T13:15:00.498Z', + scheduledTaskId: '52125fb0-5895-11ec-ae69-bb65d1a71b72', + executionStatus: { + status: 'ok' as const, + lastExecutionDate: '2024-03-21T13:15:00.498Z', + lastDuration: 1194, + }, + revision: 0, + }, + ], + }; + + // @ts-expect-error: TS complains about group being undefined in the system action + rulesClient.find.mockResolvedValueOnce(findResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + query: { + per_page: 1, + page: 1, + default_search_operator: 'OR', + }, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "data": Array [ + Object { + "actions": Array [ + Object { + "connector_type_id": ".server-log", + "group": "threshold met", + "id": "3619a0d0-582b-11ec-8995-2b1578a3bc5d", + "params": Object { + "message": "alert 37: {{context.message}}", + }, + "uuid": "123-456", + }, + Object { + "connector_type_id": ".test", + "id": "system_action-id", + "params": Object {}, + "uuid": "789", + }, + ], + "apiKey": null, + "api_key_owner": "2889684073", + "consumer": "alerts", + "created_at": "2024-03-21T13:15:00.498Z", + "created_by": "elastic", + "enabled": true, + "execution_status": Object { + "last_duration": 1194, + "last_execution_date": "2024-03-21T13:15:00.498Z", + "status": "ok", + }, + "id": "3d534c70-582b-11ec-8995-2b1578a3bc5d", + "mute_all": false, + "muted_alert_ids": Array [], + "name": "stressing index-threshold 37/200", + "notify_when": "onActiveAlert", + "params": Object { + "x": 42, + }, + "revision": 0, + "rule_type_id": ".index-threshold", + "schedule": Object { + "interval": "1s", + }, + "scheduled_task_id": "52125fb0-5895-11ec-ae69-bb65d1a71b72", + "snooze_schedule": undefined, + "tags": Array [], + "throttle": null, + "updated_at": "2024-03-21T13:15:00.498Z", + "updated_by": "2889684073", + }, + ], + "page": 1, + "per_page": 1, + "total": 0, + }, + } + `); + + expect(rulesClient.find).toHaveBeenCalledTimes(1); + expect(rulesClient.find.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "excludeFromPublicApi": true, + "includeSnoozeData": true, + "options": Object { + "defaultSearchOperator": "OR", + "filterConsumers": undefined, + "page": 1, + "perPage": 1, + }, + }, + ] + `); + + expect(res.ok).toHaveBeenCalledWith({ + body: { + page: 1, + per_page: 1, + total: 0, + data: [ + { + actions: [ + { + connector_type_id: '.server-log', + group: 'threshold met', + id: '3619a0d0-582b-11ec-8995-2b1578a3bc5d', + params: { + message: 'alert 37: {{context.message}}', + }, + uuid: '123-456', + }, + { + connector_type_id: '.test', + id: 'system_action-id', + params: {}, + uuid: '789', + }, + ], + apiKey: null, + api_key_owner: '2889684073', + consumer: 'alerts', + created_at: '2024-03-21T13:15:00.498Z', + created_by: 'elastic', + enabled: true, + execution_status: { + last_duration: 1194, + last_execution_date: '2024-03-21T13:15:00.498Z', + status: 'ok', + }, + id: '3d534c70-582b-11ec-8995-2b1578a3bc5d', + mute_all: false, + muted_alert_ids: [], + name: 'stressing index-threshold 37/200', + notify_when: 'onActiveAlert', + params: { + x: 42, + }, + revision: 0, + rule_type_id: '.index-threshold', + schedule: { + interval: '1s', + }, + scheduled_task_id: '52125fb0-5895-11ec-ae69-bb65d1a71b72', + snooze_schedule: undefined, + tags: [], + throttle: null, + updated_at: '2024-03-21T13:15:00.498Z', + updated_by: '2889684073', + }, + ], + }, + }); + }); + it('ensures the license allows finding rules', async () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); diff --git a/x-pack/plugins/alerting/server/routes/find_rules.ts b/x-pack/plugins/alerting/server/routes/find_rules.ts index 1eab92db82383..711baa3108f35 100644 --- a/x-pack/plugins/alerting/server/routes/find_rules.ts +++ b/x-pack/plugins/alerting/server/routes/find_rules.ts @@ -10,12 +10,7 @@ import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import { schema } from '@kbn/config-schema'; import { ILicenseState } from '../lib'; import { FindOptions, FindResult } from '../rules_client'; -import { - RewriteRequestCase, - RewriteResponseCase, - verifyAccessAndContext, - rewriteRule, -} from './lib'; +import { RewriteRequestCase, verifyAccessAndContext, rewriteRule } from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, @@ -69,11 +64,7 @@ const rewriteQueryReq: RewriteRequestCase = ({ ...(hasReference ? { hasReference } : {}), ...(searchFields ? { searchFields } : {}), }); -const rewriteBodyRes: RewriteResponseCase> = ({ - perPage, - data, - ...restOfResult -}) => { +const rewriteBodyRes = ({ perPage, data, ...restOfResult }: FindResult) => { return { ...restOfResult, per_page: perPage, diff --git a/x-pack/plugins/alerting/server/routes/get_rule.test.ts b/x-pack/plugins/alerting/server/routes/get_rule.test.ts index 864a848b8ffa8..8750e382f7811 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule.test.ts @@ -12,8 +12,7 @@ import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { rulesClientMock } from '../rules_client.mock'; -import { SanitizedRule } from '../types'; -import { AsApiContract } from './lib'; +import { RuleAction, RuleSystemAction, SanitizedRule } from '../types'; const rulesClient = rulesClientMock.create(); jest.mock('../lib/license_api_access', () => ({ @@ -25,6 +24,37 @@ beforeEach(() => { }); describe('getRuleRoute', () => { + const action: RuleAction = { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '123-456', + alertsFilter: { + query: { + kql: 'name:test', + dsl: '{"must": {"term": { "name": "test" }}}', + filters: [], + }, + timeframe: { + days: [1], + hours: { start: '08:00', end: '17:00' }, + timezone: 'UTC', + }, + }, + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; + const mockedAlert: SanitizedRule<{ bar: boolean; }> = { @@ -36,30 +66,7 @@ describe('getRuleRoute', () => { }, createdAt: new Date(), updatedAt: new Date(), - actions: [ - { - group: 'default', - id: '2', - actionTypeId: 'test', - params: { - foo: true, - }, - uuid: '123-456', - alertsFilter: { - query: { - kql: 'name:test', - // @ts-expect-error upgrade typescript v4.9.5 - dsl: '{"must": {"term": { "name": "test" }}}', - filters: [], - }, - timeframe: { - days: [1], - hours: { start: '08:00', end: '17:00' }, - timezone: 'UTC', - }, - }, - }, - ], + actions: [action], consumer: 'bar', name: 'abc', tags: ['foo'], @@ -78,7 +85,8 @@ describe('getRuleRoute', () => { revision: 0, }; - const getResult: AsApiContract> = { + const mockedAction0 = mockedAlert.actions[0]; + const getResult = { ...pick(mockedAlert, 'consumer', 'name', 'schedule', 'tags', 'params', 'throttle', 'enabled'), rule_type_id: mockedAlert.alertTypeId, notify_when: mockedAlert.notifyWhen, @@ -97,12 +105,12 @@ describe('getRuleRoute', () => { }, actions: [ { - group: mockedAlert.actions[0].group, - id: mockedAlert.actions[0].id, - params: mockedAlert.actions[0].params, - connector_type_id: mockedAlert.actions[0].actionTypeId, - uuid: mockedAlert.actions[0].uuid, - alerts_filter: mockedAlert.actions[0].alertsFilter, + group: mockedAction0.group, + id: mockedAction0.id, + params: mockedAction0.params, + connector_type_id: mockedAction0.actionTypeId, + uuid: mockedAction0.uuid, + alerts_filter: mockedAction0.alertsFilter, }, ], }; @@ -184,4 +192,66 @@ describe('getRuleRoute', () => { expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); }); + + it('transforms the system actions in the response of the rules client correctly', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getRuleRoute(router, licenseState); + const [_, handler] = router.get.mock.calls[0]; + + rulesClient.get.mockResolvedValueOnce({ + ...mockedAlert, + actions: [action], + systemActions: [systemAction], + }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.systemActions).toBeUndefined(); + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + alerts_filter: { + query: { + dsl: '{"must": {"term": { "name": "test" }}}', + filters: [], + kql: 'name:test', + }, + timeframe: { + days: [1], + hours: { + end: '17:00', + start: '08:00', + }, + timezone: 'UTC', + }, + }, + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/get_rule.ts b/x-pack/plugins/alerting/server/routes/get_rule.ts index bc7983f6acdf1..f739f946975e6 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule.ts @@ -9,12 +9,7 @@ import { omit } from 'lodash'; import { schema } from '@kbn/config-schema'; import { IRouter } from '@kbn/core/server'; import { ILicenseState } from '../lib'; -import { - verifyAccessAndContext, - RewriteResponseCase, - rewriteRuleLastRun, - rewriteActionsRes, -} from './lib'; +import { verifyAccessAndContext, rewriteRuleLastRun } from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, @@ -22,12 +17,13 @@ import { INTERNAL_BASE_ALERTING_API_PATH, SanitizedRule, } from '../types'; +import { transformRuleActions } from './rule/transforms'; const paramSchema = schema.object({ id: schema.string(), }); -const rewriteBodyRes: RewriteResponseCase> = ({ +const rewriteBodyRes = ({ alertTypeId, createdBy, updatedBy, @@ -40,6 +36,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ mutedInstanceIds, executionStatus, actions, + systemActions, scheduledTaskId, snoozeSchedule, isSnoozedUntil, @@ -47,7 +44,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ nextRun, viewInAppRelativeUrl, ...rest -}) => ({ +}: SanitizedRule) => ({ ...rest, rule_type_id: alertTypeId, created_by: createdBy, @@ -66,7 +63,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ last_execution_date: executionStatus.lastExecutionDate, last_duration: executionStatus.lastDuration, }, - actions: rewriteActionsRes(actions), + actions: transformRuleActions(actions, systemActions), ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), ...(nextRun ? { next_run: nextRun } : {}), ...(viewInAppRelativeUrl ? { view_in_app_relative_url: viewInAppRelativeUrl } : {}), diff --git a/x-pack/plugins/alerting/server/routes/legacy/create.test.ts b/x-pack/plugins/alerting/server/routes/legacy/create.test.ts index 548015ef55f03..e090cac73a932 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/create.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/create.test.ts @@ -12,7 +12,7 @@ import { licenseStateMock } from '../../lib/license_state.mock'; import { verifyApiAccess } from '../../lib/license_api_access'; import { mockHandlerArguments } from '../_mock_handler_arguments'; import { rulesClientMock } from '../../rules_client.mock'; -import { Rule } from '../../../common/rule'; +import { Rule, RuleSystemAction } from '../../../common/rule'; import { RuleTypeDisabledError } from '../../lib/errors/rule_type_disabled'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage'; @@ -57,6 +57,15 @@ describe('createAlertRoute', () => { ], }; + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; + const createResult: Rule<{ bar: boolean }> = { ...mockedAlert, enabled: true, @@ -460,8 +469,81 @@ describe('createAlertRoute', () => { usageCounter: mockUsageCounter, }); const [, handler] = router.post.mock.calls[0]; + rulesClient.create.mockResolvedValueOnce(createResult); const [context, req, res] = mockHandlerArguments({ rulesClient }, {}, ['ok']); await handler(context, req, res); expect(trackLegacyRouteUsage).toHaveBeenCalledWith('create', mockUsageCounter); }); + + it('does not return system actions', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + + createAlertRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id?}"`); + + rulesClient.create.mockResolvedValueOnce({ ...createResult, systemActions: [systemAction] }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + body: mockedAlert, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toEqual({ body: createResult }); + + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); + expect(rulesClient.create).toHaveBeenCalledTimes(1); + expect(rulesClient.create.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "actions": Array [ + Object { + "group": "default", + "id": "2", + "params": Object { + "foo": true, + }, + }, + ], + "alertTypeId": "1", + "consumer": "bar", + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "tags": Array [ + "foo", + ], + "throttle": "30s", + }, + "options": Object { + "id": undefined, + }, + }, + ] + `); + + expect(res.ok).toHaveBeenCalledWith({ + body: createResult, + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/legacy/create.ts b/x-pack/plugins/alerting/server/routes/legacy/create.ts index e7583033ae4ba..f586a253696b3 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/create.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/create.ts @@ -78,10 +78,11 @@ export const createAlertRoute = ({ router, licenseState, usageCounter }: RouteOp }); try { - const alertRes: SanitizedRule = await rulesClient.create({ - data: { ...alert, notifyWhen }, - options: { id: params?.id }, - }); + const { systemActions, ...alertRes }: SanitizedRule = + await rulesClient.create({ + data: { ...alert, notifyWhen }, + options: { id: params?.id }, + }); return res.ok({ body: alertRes, }); diff --git a/x-pack/plugins/alerting/server/routes/legacy/find.test.ts b/x-pack/plugins/alerting/server/routes/legacy/find.test.ts index 026a4f81688e3..60ca90865be1d 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/find.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/find.test.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + +import { omit } from 'lodash'; import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; import { findAlertRoute } from './find'; import { httpServiceMock } from '@kbn/core/server/mocks'; @@ -160,6 +162,13 @@ describe('findAlertRoute', () => { findAlertRoute(router, licenseState, mockUsageCounter); const [, handler] = router.get.mock.calls[0]; + const findResult = { + page: 1, + perPage: 1, + total: 0, + data: [], + }; + rulesClient.find.mockResolvedValueOnce(findResult); const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, query: {} }, [ 'ok', ]); @@ -175,6 +184,14 @@ describe('findAlertRoute', () => { findAlertRoute(router, licenseState, mockUsageCounter); const [, handler] = router.get.mock.calls[0]; + + const findResult = { + page: 1, + perPage: 1, + total: 0, + data: [], + }; + rulesClient.find.mockResolvedValueOnce(findResult); const [context, req, res] = mockHandlerArguments( { rulesClient }, { @@ -204,6 +221,13 @@ describe('findAlertRoute', () => { findAlertRoute(router, licenseState, mockUsageCounter); const [, handler] = router.get.mock.calls[0]; + const findResult = { + page: 1, + perPage: 1, + total: 0, + data: [], + }; + rulesClient.find.mockResolvedValueOnce(findResult); const [context, req, res] = mockHandlerArguments( { rulesClient }, { @@ -221,4 +245,153 @@ describe('findAlertRoute', () => { incrementBy: 1, }); }); + + it('does not return system actions', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + findAlertRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/alerts/_find"`); + + const findResult = { + page: 1, + perPage: 1, + total: 0, + data: [ + { + id: '3d534c70-582b-11ec-8995-2b1578a3bc5d', + notifyWhen: 'onActiveAlert' as const, + alertTypeId: '.index-threshold', + name: 'stressing index-threshold 37/200', + consumer: 'alerts', + tags: [], + enabled: true, + throttle: null, + apiKey: null, + apiKeyOwner: '2889684073', + createdBy: 'elastic', + updatedBy: '2889684073', + muteAll: false, + mutedInstanceIds: [], + schedule: { + interval: '1s', + }, + actions: [ + { + actionTypeId: '.server-log', + params: { + message: 'alert 37: {{context.message}}', + }, + group: 'threshold met', + id: '3619a0d0-582b-11ec-8995-2b1578a3bc5d', + uuid: '123-456', + }, + ], + systemActions: [ + { actionTypeId: '.test', id: 'system_action-id', params: {}, uuid: '789' }, + ], + params: { x: 42 }, + updatedAt: '2024-03-21T13:15:00.498Z', + createdAt: '2024-03-21T13:15:00.498Z', + scheduledTaskId: '52125fb0-5895-11ec-ae69-bb65d1a71b72', + executionStatus: { + status: 'ok' as const, + lastExecutionDate: '2024-03-21T13:15:00.498Z', + lastDuration: 1194, + }, + revision: 0, + }, + ], + }; + + // @ts-expect-error: TS complains about dates being string and not a Date object + rulesClient.find.mockResolvedValueOnce(findResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + query: { + per_page: 1, + page: 1, + default_search_operator: 'OR', + }, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "data": Array [ + Object { + "actions": Array [ + Object { + "actionTypeId": ".server-log", + "group": "threshold met", + "id": "3619a0d0-582b-11ec-8995-2b1578a3bc5d", + "params": Object { + "message": "alert 37: {{context.message}}", + }, + "uuid": "123-456", + }, + ], + "alertTypeId": ".index-threshold", + "apiKey": null, + "apiKeyOwner": "2889684073", + "consumer": "alerts", + "createdAt": "2024-03-21T13:15:00.498Z", + "createdBy": "elastic", + "enabled": true, + "executionStatus": Object { + "lastDuration": 1194, + "lastExecutionDate": "2024-03-21T13:15:00.498Z", + "status": "ok", + }, + "id": "3d534c70-582b-11ec-8995-2b1578a3bc5d", + "muteAll": false, + "mutedInstanceIds": Array [], + "name": "stressing index-threshold 37/200", + "notifyWhen": "onActiveAlert", + "params": Object { + "x": 42, + }, + "revision": 0, + "schedule": Object { + "interval": "1s", + }, + "scheduledTaskId": "52125fb0-5895-11ec-ae69-bb65d1a71b72", + "tags": Array [], + "throttle": null, + "updatedAt": "2024-03-21T13:15:00.498Z", + "updatedBy": "2889684073", + }, + ], + "page": 1, + "perPage": 1, + "total": 0, + }, + } + `); + + expect(rulesClient.find).toHaveBeenCalledTimes(1); + expect(rulesClient.find.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "excludeFromPublicApi": true, + "options": Object { + "defaultSearchOperator": "OR", + "page": 1, + "perPage": 1, + }, + }, + ] + `); + + expect(res.ok).toHaveBeenCalledWith({ + body: omit(findResult, 'data[0].systemActions'), + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/legacy/find.ts b/x-pack/plugins/alerting/server/routes/legacy/find.ts index 9a33f5b2dc5fd..e0e4ffa34cf33 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/find.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/find.ts @@ -99,7 +99,10 @@ export const findAlertRoute = ( const findResult = await rulesClient.find({ options, excludeFromPublicApi: true }); return res.ok({ - body: findResult, + body: { + ...findResult, + data: findResult.data.map(({ systemActions, ...rule }) => rule), + }, }); }) ); diff --git a/x-pack/plugins/alerting/server/routes/legacy/get.test.ts b/x-pack/plugins/alerting/server/routes/legacy/get.test.ts index 403f7a5b42ac8..7d1a6ac656896 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/get.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/get.test.ts @@ -12,7 +12,7 @@ import { licenseStateMock } from '../../lib/license_state.mock'; import { verifyApiAccess } from '../../lib/license_api_access'; import { mockHandlerArguments } from '../_mock_handler_arguments'; import { rulesClientMock } from '../../rules_client.mock'; -import { Rule } from '../../../common'; +import { Rule, RuleSystemAction } from '../../../common'; import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage'; const rulesClient = rulesClientMock.create(); @@ -69,6 +69,15 @@ describe('getAlertRoute', () => { revision: 0, }; + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; + it('gets an alert with proper parameters', async () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); @@ -155,10 +164,41 @@ describe('getAlertRoute', () => { getAlertRoute(router, licenseState, mockUsageCounter); const [, handler] = router.get.mock.calls[0]; + + rulesClient.get.mockResolvedValueOnce(mockedAlert); + const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: { id: '1' } }, [ 'ok', ]); await handler(context, req, res); expect(trackLegacyRouteUsage).toHaveBeenCalledWith('get', mockUsageCounter); }); + + it('does not return system actions', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getAlertRoute(router, licenseState); + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}"`); + + rulesClient.get.mockResolvedValueOnce({ ...mockedAlert, systemActions: [systemAction] }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + await handler(context, req, res); + + expect(rulesClient.get).toHaveBeenCalledTimes(1); + expect(rulesClient.get.mock.calls[0][0].id).toEqual('1'); + + expect(res.ok).toHaveBeenCalledWith({ + body: mockedAlert, + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/legacy/get.ts b/x-pack/plugins/alerting/server/routes/legacy/get.ts index 62fdde5507148..be9550f1f336e 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/get.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/get.ts @@ -37,8 +37,9 @@ export const getAlertRoute = ( trackLegacyRouteUsage('get', usageCounter); const rulesClient = (await context.alerting).getRulesClient(); const { id } = req.params; + const { systemActions, ...rule } = await rulesClient.get({ id, excludeFromPublicApi: true }); return res.ok({ - body: await rulesClient.get({ id, excludeFromPublicApi: true }), + body: rule, }); }) ); diff --git a/x-pack/plugins/alerting/server/routes/legacy/update.test.ts b/x-pack/plugins/alerting/server/routes/legacy/update.test.ts index 756e751e5c6e4..f6436fb91cbdc 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/update.test.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/update.test.ts @@ -13,7 +13,7 @@ import { verifyApiAccess } from '../../lib/license_api_access'; import { mockHandlerArguments } from '../_mock_handler_arguments'; import { rulesClientMock } from '../../rules_client.mock'; import { RuleTypeDisabledError } from '../../lib/errors/rule_type_disabled'; -import { RuleNotifyWhen } from '../../../common'; +import { RuleNotifyWhen, RuleSystemAction } from '../../../common'; import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage'; const rulesClient = rulesClientMock.create(); @@ -53,6 +53,15 @@ describe('updateAlertRoute', () => { notifyWhen: RuleNotifyWhen.CHANGE, }; + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; + it('updates an alert with proper parameters', async () => { const licenseState = licenseStateMock.create(); const router = httpServiceMock.createRouter(); @@ -249,10 +258,89 @@ describe('updateAlertRoute', () => { updateAlertRoute(router, licenseState, mockUsageCounter); const [, handler] = router.put.mock.calls[0]; + rulesClient.update.mockResolvedValueOnce(mockedResponse); const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, body: {} }, [ 'ok', ]); await handler(context, req, res); expect(trackLegacyRouteUsage).toHaveBeenCalledWith('update', mockUsageCounter); }); + + it('does not return system actions', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateAlertRoute(router, licenseState); + + const [config, handler] = router.put.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}"`); + + rulesClient.update.mockResolvedValueOnce({ ...mockedResponse, systemActions: [systemAction] }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + body: { + throttle: null, + name: 'abc', + tags: ['bar'], + schedule: { interval: '12s' }, + params: { + otherField: false, + }, + actions: [ + { + group: 'default', + id: '2', + params: { + baz: true, + }, + }, + ], + notifyWhen: 'onActionGroupChange', + }, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toEqual({ body: mockedResponse }); + + expect(rulesClient.update).toHaveBeenCalledTimes(1); + expect(rulesClient.update.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "actions": Array [ + Object { + "group": "default", + "id": "2", + "params": Object { + "baz": true, + }, + }, + ], + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object { + "otherField": false, + }, + "schedule": Object { + "interval": "12s", + }, + "tags": Array [ + "bar", + ], + "throttle": null, + }, + "id": "1", + }, + ] + `); + + expect(res.ok).toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/legacy/update.ts b/x-pack/plugins/alerting/server/routes/legacy/update.ts index 07bde524076c1..203352dd01a6b 100644 --- a/x-pack/plugins/alerting/server/routes/legacy/update.ts +++ b/x-pack/plugins/alerting/server/routes/legacy/update.ts @@ -68,7 +68,7 @@ export const updateAlertRoute = ( const { id } = req.params; const { name, actions, params, schedule, tags, throttle, notifyWhen } = req.body; try { - const alertRes = await rulesClient.update({ + const { systemActions, ...alertRes } = await rulesClient.update({ id, data: { name, diff --git a/x-pack/plugins/alerting/server/routes/lib/actions_schema.ts b/x-pack/plugins/alerting/server/routes/lib/actions_schema.ts index 76661b2d33ff2..8b3d136d84f46 100644 --- a/x-pack/plugins/alerting/server/routes/lib/actions_schema.ts +++ b/x-pack/plugins/alerting/server/routes/lib/actions_schema.ts @@ -13,7 +13,7 @@ import { validateHours } from './validate_hours'; export const actionsSchema = schema.arrayOf( schema.object({ - group: schema.string(), + group: schema.maybe(schema.string()), id: schema.string(), params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), frequency: schema.maybe( @@ -80,3 +80,14 @@ export const actionsSchema = schema.arrayOf( }), { defaultValue: [] } ); + +export const systemActionsSchema = schema.maybe( + schema.arrayOf( + schema.object({ + id: schema.string(), + params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), + uuid: schema.maybe(schema.string()), + }), + { defaultValue: [] } + ) +); diff --git a/x-pack/plugins/alerting/server/routes/lib/index.ts b/x-pack/plugins/alerting/server/routes/lib/index.ts index af4a5d4309f86..8ad24d232272a 100644 --- a/x-pack/plugins/alerting/server/routes/lib/index.ts +++ b/x-pack/plugins/alerting/server/routes/lib/index.ts @@ -18,7 +18,7 @@ export type { } from './rewrite_request_case'; export { verifyAccessAndContext } from './verify_access_and_context'; export { countUsageOfPredefinedIds } from './count_usage_of_predefined_ids'; -export { rewriteActionsReq, rewriteActionsRes } from './rewrite_actions'; +export { rewriteActionsReq, rewriteSystemActionsReq } from './rewrite_actions'; export { actionsSchema } from './actions_schema'; export { rewriteRule, rewriteRuleLastRun } from './rewrite_rule'; export { rewriteNamespaces } from './rewrite_namespaces'; diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.test.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.test.ts index 61dc9282bbfa1..69083a3b2013f 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.test.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.test.ts @@ -5,63 +5,10 @@ * 2.0. */ -import { rewriteActionsReq, rewriteActionsRes } from './rewrite_actions'; +import { rewriteActionsReq, rewriteSystemActionsReq } from './rewrite_actions'; -describe('rewrite Actions', () => { - describe('rewriteActionsRes', () => { - it('rewrites the actions response correctly', () => { - expect( - rewriteActionsRes([ - { - uuid: '111', - group: 'default', - id: '1', - actionTypeId: '2', - params: { foo: 'bar' }, - frequency: { - summary: true, - notifyWhen: 'onThrottleInterval', - throttle: '1h', - }, - alertsFilter: { - query: { - kql: 'test:1s', - dsl: '{test:1}', - filters: [], - }, - timeframe: { - days: [1, 2, 3], - timezone: 'UTC', - hours: { - start: '00:00', - end: '15:00', - }, - }, - }, - }, - ]) - ).toEqual([ - { - alerts_filter: { - query: { dsl: '{test:1}', kql: 'test:1s', filters: [] }, - timeframe: { - days: [1, 2, 3], - hours: { end: '15:00', start: '00:00' }, - timezone: 'UTC', - }, - }, - connector_type_id: '2', - frequency: { notify_when: 'onThrottleInterval', summary: true, throttle: '1h' }, - group: 'default', - id: '1', - params: { foo: 'bar' }, - uuid: '111', - }, - ]); - }); - }); - - describe('rewriteActionsReq', () => { +describe('rewriteActionsReq', () => { + it('should rewrite actions correctly', () => { expect( rewriteActionsReq([ { @@ -121,3 +68,23 @@ describe('rewrite Actions', () => { ]); }); }); + +describe('rewriteSystemActionsReq', () => { + it('should rewrite system actions correctly', () => { + expect( + rewriteSystemActionsReq([ + { + uuid: '111', + id: 'system-1', + params: { foo: 'bar' }, + }, + ]) + ).toEqual([ + { + uuid: '111', + id: 'system-1', + params: { foo: 'bar' }, + }, + ]); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.ts index 96c2275d113a2..00cd7cbd45c3f 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_actions.ts @@ -6,12 +6,11 @@ */ import { TypeOf } from '@kbn/config-schema/src/types/object_type'; import { omit } from 'lodash'; -import { NormalizedAlertAction } from '../../rules_client'; -import { RuleAction } from '../../types'; -import { actionsSchema } from './actions_schema'; +import { NormalizedAlertAction, NormalizedSystemAction } from '../../rules_client'; +import { actionsSchema, systemActionsSchema } from './actions_schema'; export const rewriteActionsReq = ( - actions?: TypeOf + actions: TypeOf ): NormalizedAlertAction[] => { if (!actions) return []; @@ -23,12 +22,17 @@ export const rewriteActionsReq = ( ...action }) => { return { - ...action, + group: action.group ?? 'default', + id: action.id, + params: action.params, + ...(action.uuid ? { uuid: action.uuid } : {}), ...(typeof useAlertDataForTemplate !== 'undefined' ? { useAlertDataForTemplate } : {}), ...(frequency ? { frequency: { ...omit(frequency, 'notify_when'), + summary: frequency.summary, + throttle: frequency.throttle, notifyWhen: frequency.notify_when, }, } @@ -39,25 +43,16 @@ export const rewriteActionsReq = ( ); }; -export const rewriteActionsRes = (actions?: RuleAction[]) => { - const rewriteFrequency = ({ notifyWhen, ...rest }: NonNullable) => ({ - ...rest, - notify_when: notifyWhen, - }); +export const rewriteSystemActionsReq = ( + actions: TypeOf +): NormalizedSystemAction[] => { if (!actions) return []; - return actions.map( - ({ actionTypeId, frequency, alertsFilter, useAlertDataForTemplate, ...action }) => ({ - ...action, - connector_type_id: actionTypeId, - ...(typeof useAlertDataForTemplate !== 'undefined' - ? { use_alert_data_for_template: useAlertDataForTemplate } - : {}), - ...(frequency ? { frequency: rewriteFrequency(frequency) } : {}), - ...(alertsFilter - ? { - alerts_filter: alertsFilter, - } - : {}), - }) - ); + + return actions.map((action) => { + return { + id: action.id, + params: action.params, + ...(action.uuid ? { uuid: action.uuid } : {}), + }; + }); }; diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts index 4ad0dc817fbbe..854d6cc4294a5 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.test.ts @@ -35,16 +35,22 @@ const sampleRule: SanitizedRule & { activeSnoozes?: string[] } = actions: [ { group: 'default', - id: 'aaa', - actionTypeId: 'bbb', + id: '1001', + actionTypeId: '.test-system-action', params: {}, frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1m', }, - // @ts-expect-error upgrade typescript v4.9.5 - alertsFilter: { query: { kql: 'test:1', dsl: '{}', filters: [] } }, + alertsFilter: { query: { kql: 'test:1', filters: [] } }, + }, + ], + systemActions: [ + { + id: 'ccc', + actionTypeId: 'ddd', + params: {}, }, ], scheduledTaskId: 'xyz456', @@ -82,13 +88,31 @@ describe('rewriteRule', () => { }); it('should rewrite actions correctly', () => { const rewritten = rewriteRule(sampleRule); - for (const action of rewritten.actions) { - expect(Object.keys(action)).toEqual( - expect.arrayContaining(['group', 'id', 'connector_type_id', 'params', 'frequency']) - ); - expect(Object.keys(action.frequency!)).toEqual( - expect.arrayContaining(['summary', 'notify_when', 'throttle']) - ); - } + expect(rewritten.actions).toMatchInlineSnapshot(` + Array [ + Object { + "alerts_filter": Object { + "query": Object { + "filters": Array [], + "kql": "test:1", + }, + }, + "connector_type_id": ".test-system-action", + "frequency": Object { + "notify_when": "onThrottleInterval", + "summary": false, + "throttle": "1m", + }, + "group": "default", + "id": "1001", + "params": Object {}, + }, + Object { + "connector_type_id": "ddd", + "id": "ccc", + "params": Object {}, + }, + ] + `); }); }); diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts index d0e59278b13c5..a95e477d55ae1 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts @@ -31,6 +31,7 @@ export const rewriteRule = ({ mutedInstanceIds, executionStatus, actions, + systemActions, scheduledTaskId, snoozeSchedule, isSnoozedUntil, @@ -39,45 +40,71 @@ export const rewriteRule = ({ nextRun, alertDelay, ...rest -}: SanitizedRule & { activeSnoozes?: string[] }) => ({ - ...rest, - rule_type_id: alertTypeId, - created_by: createdBy, - updated_by: updatedBy, - created_at: createdAt, - updated_at: updatedAt, - api_key_owner: apiKeyOwner, - notify_when: notifyWhen, - mute_all: muteAll, - muted_alert_ids: mutedInstanceIds, - scheduled_task_id: scheduledTaskId, - snooze_schedule: snoozeSchedule, - ...(isSnoozedUntil != null ? { is_snoozed_until: isSnoozedUntil } : {}), - ...(activeSnoozes != null ? { active_snoozes: activeSnoozes } : {}), - execution_status: executionStatus && { - ...omit(executionStatus, 'lastExecutionDate', 'lastDuration'), - last_execution_date: executionStatus.lastExecutionDate, - last_duration: executionStatus.lastDuration, - }, - actions: actions.map(({ group, id, actionTypeId, params, frequency, uuid, alertsFilter }) => ({ - group, - id, - params, - connector_type_id: actionTypeId, - ...(frequency - ? { - frequency: { - summary: frequency.summary, - notify_when: frequency.notifyWhen, - throttle: frequency.throttle, - }, - } - : {}), - ...(uuid && { uuid }), - ...(alertsFilter && { alerts_filter: alertsFilter }), - })), - ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), - ...(nextRun ? { next_run: nextRun } : {}), - ...(apiKeyCreatedByUser !== undefined ? { api_key_created_by_user: apiKeyCreatedByUser } : {}), - ...(alertDelay !== undefined ? { alert_delay: alertDelay } : {}), -}); +}: SanitizedRule & { activeSnoozes?: string[] }) => { + const actionsTemp: unknown[] = []; + actions.forEach((action) => { + const { + id, + actionTypeId, + params, + uuid, + useAlertDataForTemplate, + group, + frequency, + alertsFilter, + } = action; + actionsTemp.push({ + group, + id, + params, + connector_type_id: actionTypeId, + ...(frequency + ? { + frequency: { + summary: frequency.summary, + notify_when: frequency.notifyWhen, + throttle: frequency.throttle, + }, + } + : {}), + ...(uuid && { uuid }), + ...(alertsFilter && { alerts_filter: alertsFilter }), + ...(typeof useAlertDataForTemplate !== 'undefined' + ? { use_alert_data_for_template: useAlertDataForTemplate } + : {}), + }); + }); + (systemActions ?? []).forEach((systemAction) => { + const { actionTypeId, ...restSystemAction } = systemAction; + actionsTemp.push({ + ...restSystemAction, + connector_type_id: actionTypeId, + }); + }); + return { + ...rest, + rule_type_id: alertTypeId, + created_by: createdBy, + updated_by: updatedBy, + created_at: createdAt, + updated_at: updatedAt, + api_key_owner: apiKeyOwner, + notify_when: notifyWhen, + mute_all: muteAll, + muted_alert_ids: mutedInstanceIds, + scheduled_task_id: scheduledTaskId, + snooze_schedule: snoozeSchedule, + ...(isSnoozedUntil != null ? { is_snoozed_until: isSnoozedUntil } : {}), + ...(activeSnoozes != null ? { active_snoozes: activeSnoozes } : {}), + execution_status: executionStatus && { + ...omit(executionStatus, 'lastExecutionDate', 'lastDuration'), + last_execution_date: executionStatus.lastExecutionDate, + last_duration: executionStatus.lastDuration, + }, + actions: actionsTemp, + ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), + ...(nextRun ? { next_run: nextRun } : {}), + ...(apiKeyCreatedByUser !== undefined ? { api_key_created_by_user: apiKeyCreatedByUser } : {}), + ...(alertDelay !== undefined ? { alert_delay: alertDelay } : {}), + }; +}; diff --git a/x-pack/plugins/alerting/server/routes/lib/validate_required_group_in_default_actions.test.ts b/x-pack/plugins/alerting/server/routes/lib/validate_required_group_in_default_actions.test.ts new file mode 100644 index 0000000000000..6e30fbe312585 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/lib/validate_required_group_in_default_actions.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { validateRequiredGroupInDefaultActions } from './validate_required_group_in_default_actions'; + +describe('validateRequiredGroupInDefaultActions', () => { + const isSystemAction = jest.fn().mockImplementation((id) => id === 'system_action-id'); + + it('throws an error if the action is missing the group', () => { + expect(() => + validateRequiredGroupInDefaultActions([{ id: 'test' }], isSystemAction) + ).toThrowErrorMatchingInlineSnapshot(`"Group is not defined in action test"`); + }); + + it('does not throw an error if the action has the group', () => { + expect(() => + validateRequiredGroupInDefaultActions([{ id: 'test', group: 'default' }], isSystemAction) + ).not.toThrow(); + }); + + it('does not throw an error if the action is a system action and is missing the group', () => { + expect(() => + validateRequiredGroupInDefaultActions([{ id: 'system_action-id' }], isSystemAction) + ).not.toThrow(); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/lib/validate_required_group_in_default_actions.ts b/x-pack/plugins/alerting/server/routes/lib/validate_required_group_in_default_actions.ts new file mode 100644 index 0000000000000..f34b2a7046832 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/lib/validate_required_group_in_default_actions.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; + +export const validateRequiredGroupInDefaultActions = ( + actions: Array<{ id: string; group?: string }>, + isSystemAction: (id: string) => boolean +) => { + const defaultActions = actions.filter((action) => !isSystemAction(action.id)); + + for (const action of defaultActions) { + if (!action.group) { + throw Boom.badRequest(`Group is not defined in action ${action.id}`); + } + } +}; diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_delete/bulk_delete_rules_route.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_delete/bulk_delete_rules_route.test.ts index 21099f6ba6086..3f2a31ac6cc96 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_delete/bulk_delete_rules_route.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_delete/bulk_delete_rules_route.test.ts @@ -6,13 +6,14 @@ */ import { httpServiceMock } from '@kbn/core/server/mocks'; - +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; import { bulkDeleteRulesRoute } from './bulk_delete_rules_route'; import { licenseStateMock } from '../../../../lib/license_state.mock'; import { mockHandlerArguments } from '../../../_mock_handler_arguments'; import { rulesClientMock } from '../../../../rules_client.mock'; import { RuleTypeDisabledError } from '../../../../lib/errors/rule_type_disabled'; import { verifyApiAccess } from '../../../../lib/license_api_access'; +import { RuleAction, RuleSystemAction, SanitizedRule } from '../../../../types'; const rulesClient = rulesClientMock.create(); @@ -123,4 +124,119 @@ describe('bulkDeleteRulesRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + describe('actions', () => { + const mockedRule: SanitizedRule<{}> = { + id: '1', + alertTypeId: '1', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + actions: [ + { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '123-456', + }, + ], + consumer: 'bar', + name: 'abc', + tags: ['foo'], + enabled: true, + muteAll: false, + notifyWhen: 'onActionGroupChange', + createdBy: '', + updatedBy: '', + apiKeyOwner: '', + throttle: '30s', + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + revision: 0, + }; + + const action: RuleAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; + + const mockedRules: Array> = [ + { ...mockedRule, actions: [action], systemActions: [systemAction] }, + ]; + + const bulkDeleteActionsResult = { + rules: mockedRules, + errors: [], + total: 1, + taskIdsFailedToBeDeleted: [], + }; + + it('merges actions and systemActions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + bulkDeleteRulesRoute({ router, licenseState }); + const [_, handler] = router.patch.mock.calls[0]; + + rulesClient.bulkDeleteRules.mockResolvedValueOnce(bulkDeleteActionsResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: bulkDeleteRequest, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.systemActions).toBeUndefined(); + // @ts-expect-error: body exists + expect(routeRes.body.rules[0].actions).toEqual([ + { + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_disable/bulk_disable_rules_route.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_disable/bulk_disable_rules_route.test.ts index a53501060a39f..cd71a78a5f51c 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_disable/bulk_disable_rules_route.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_disable/bulk_disable_rules_route.test.ts @@ -13,6 +13,8 @@ import { mockHandlerArguments } from '../../../_mock_handler_arguments'; import { rulesClientMock } from '../../../../rules_client.mock'; import { RuleTypeDisabledError } from '../../../../lib/errors/rule_type_disabled'; import { verifyApiAccess } from '../../../../lib/license_api_access'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import { RuleAction, RuleSystemAction, SanitizedRule } from '../../../../types'; const rulesClient = rulesClientMock.create(); @@ -123,4 +125,119 @@ describe('bulkDisableRulesRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + describe('actions', () => { + const mockedRule: SanitizedRule<{}> = { + id: '1', + alertTypeId: '1', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + actions: [ + { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '123-456', + }, + ], + consumer: 'bar', + name: 'abc', + tags: ['foo'], + enabled: false, + muteAll: false, + notifyWhen: 'onActionGroupChange', + createdBy: '', + updatedBy: '', + apiKeyOwner: '', + throttle: '30s', + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + revision: 0, + }; + + const action: RuleAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; + + const mockedRules: Array> = [ + { ...mockedRule, actions: [action], systemActions: [systemAction] }, + ]; + + const bulkDisableActionsResult = { + rules: mockedRules, + errors: [], + total: 1, + skipped: [], + }; + + it('merges actions and systemActions correctly before sending the response', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + bulkDisableRulesRoute({ router, licenseState }); + const [_, handler] = router.patch.mock.calls[0]; + + rulesClient.bulkDisableRules.mockResolvedValueOnce(bulkDisableActionsResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: bulkDisableRequest, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.systemActions).toBeUndefined(); + // @ts-expect-error: body exists + expect(routeRes.body.rules[0].actions).toEqual([ + { + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.test.ts index 1b1ed454c5207..82480acf779f9 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.test.ts @@ -13,7 +13,9 @@ import { verifyApiAccess } from '../../../../lib/license_api_access'; import { RuleTypeDisabledError } from '../../../../lib/errors/rule_type_disabled'; import { mockHandlerArguments } from '../../../_mock_handler_arguments'; import { rulesClientMock } from '../../../../rules_client.mock'; -import { SanitizedRule } from '../../../../types'; +import { RuleAction, RuleSystemAction, SanitizedRule } from '../../../../types'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; +import { omit } from 'lodash'; const rulesClient = rulesClientMock.create(); jest.mock('../../../../lib/license_api_access', () => ({ @@ -189,4 +191,176 @@ describe('bulkEditRulesRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + describe('actions', () => { + const action: RuleAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; + + const mockedActionAlerts: Array> = [ + { ...mockedAlert, actions: [action], systemActions: [systemAction] }, + ]; + + const bulkEditActionsRequest = { + filter: '', + operations: [ + { + operation: 'add', + field: 'actions', + value: [action, systemAction], + }, + ], + }; + + const bulkEditActionsResult = { rules: mockedActionAlerts, errors: [], total: 1, skipped: [] }; + + it('passes the system actions correctly to the rules client', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + bulkEditInternalRulesRoute(router, licenseState); + + const [_, handler] = router.post.mock.calls[0]; + + rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditActionsResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: bulkEditActionsRequest, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesClient.bulkEdit.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "filter": "", + "ids": undefined, + "operations": Array [ + Object { + "field": "actions", + "operation": "add", + "value": Array [ + Object { + "frequency": undefined, + "group": "default", + "id": "2", + "params": Object { + "foo": true, + }, + "uuid": "123-456", + }, + Object { + "id": "system_action-id", + "params": Object { + "foo": true, + }, + "uuid": "123-456", + }, + ], + }, + ], + } + `); + }); + + it('transforms the system actions in the response of the rules client correctly', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + bulkEditInternalRulesRoute(router, licenseState); + + const [_, handler] = router.post.mock.calls[0]; + + rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditActionsResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: bulkEditActionsRequest, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.systemActions).toBeUndefined(); + // @ts-expect-error: body exists + expect(routeRes.body.rules[0].actions).toEqual([ + { + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); + + it('throws an error if the default action does not specifies a group', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + bulkEditInternalRulesRoute(router, licenseState); + + const [_, handler] = router.post.mock.calls[0]; + + rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditActionsResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: { + ...bulkEditActionsRequest, + operations: [ + { + operation: 'add', + field: 'actions', + value: [omit(action, 'group')], + }, + ], + }, + }, + ['ok'] + ); + + await expect(handler(context, req, res)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Group is not defined in action 2"` + ); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.ts index ae39ceba1ceb3..ef29536014b0c 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/bulk_edit_rules_route.ts @@ -20,6 +20,8 @@ import { Rule } from '../../../../application/rule/types'; import type { RuleParamsV1 } from '../../../../../common/routes/rule/response'; import { transformRuleToRuleResponseV1 } from '../../transforms'; +import { transformOperationsV1 } from './transforms'; +import { validateRequiredGroupInDefaultActions } from '../../../lib/validate_required_group_in_default_actions'; interface BuildBulkEditRulesRouteParams { licenseState: ILicenseState; @@ -39,15 +41,24 @@ const buildBulkEditRulesRoute = ({ licenseState, path, router }: BuildBulkEditRu router.handleLegacyErrors( verifyAccessAndContext(licenseState, async function (context, req, res) { const rulesClient = (await context.alerting).getRulesClient(); - const bulkEditData: BulkEditRulesRequestBodyV1 = req.body; + const actionsClient = (await context.actions).getActionsClient(); + const bulkEditData: BulkEditRulesRequestBodyV1 = req.body; const { filter, operations, ids } = bulkEditData; try { + validateRequiredGroupInDefaultActionsInOperations( + operations ?? [], + (connectorId: string) => actionsClient.isSystemAction(connectorId) + ); + const bulkEditResults = await rulesClient.bulkEdit({ filter, ids, - operations, + operations: transformOperationsV1({ + operations, + isSystemAction: (connectorId: string) => actionsClient.isSystemAction(connectorId), + }), }); const resultBody: BulkEditRulesResponseV1 = { @@ -82,3 +93,14 @@ export const bulkEditInternalRulesRoute = ( path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_bulk_edit`, router, }); + +const validateRequiredGroupInDefaultActionsInOperations = ( + operations: BulkEditRulesRequestBodyV1['operations'], + isSystemAction: (connectorId: string) => boolean +) => { + for (const operation of operations) { + if (operation.field === 'actions') { + validateRequiredGroupInDefaultActions(operation.value, isSystemAction); + } + } +}; diff --git a/x-pack/plugins/alerting/common/routes/alerts_filter_query/constants/v1.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/index.ts similarity index 57% rename from x-pack/plugins/alerting/common/routes/alerts_filter_query/constants/v1.ts rename to x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/index.ts index bce6890c22f2c..e7d1a1dc43478 100644 --- a/x-pack/plugins/alerting/common/routes/alerts_filter_query/constants/v1.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/index.ts @@ -5,9 +5,6 @@ * 2.0. */ -export const filterStateStore = { - APP_STATE: 'appState', - GLOBAL_STATE: 'globalState', -} as const; +export { transformOperations } from './transform_operations/latest'; -export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore]; +export { transformOperations as transformOperationsV1 } from './transform_operations/v1'; diff --git a/x-pack/plugins/alerting/common/routes/alerts_filter_query/constants/latest.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/latest.ts similarity index 74% rename from x-pack/plugins/alerting/common/routes/alerts_filter_query/constants/latest.ts rename to x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/latest.ts index 6c32b4867cc0d..25300c97a6d2e 100644 --- a/x-pack/plugins/alerting/common/routes/alerts_filter_query/constants/latest.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/latest.ts @@ -5,5 +5,4 @@ * 2.0. */ -export { filterStateStore } from './v1'; -export type { FilterStateStore } from './v1'; +export * from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.test.ts new file mode 100644 index 0000000000000..23ec499a76c9e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { transformOperations } from './v1'; + +describe('transformOperations', () => { + const isSystemAction = (id: string) => id === 'my-system-action-id'; + + describe('actions', () => { + const defaultAction = { + id: 'default-action', + params: {}, + }; + + const systemAction = { + id: 'my-system-action-id', + params: {}, + }; + + it('transform the actions correctly', async () => { + expect( + transformOperations({ + operations: [ + { field: 'actions', operation: 'add', value: [defaultAction, systemAction] }, + ], + isSystemAction, + }) + ).toEqual([ + { + field: 'actions', + operation: 'add', + value: [ + { + group: 'default', + id: 'default-action', + params: {}, + }, + { id: 'my-system-action-id', params: {} }, + ], + }, + ]); + }); + + it('returns an empty array if the operations are empty', async () => { + expect( + transformOperations({ + operations: [], + isSystemAction, + }) + ).toEqual([]); + }); + + it('returns an empty array if the operations are undefined', async () => { + expect( + transformOperations({ + isSystemAction, + }) + ).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.ts b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.ts new file mode 100644 index 0000000000000..844617ab2700f --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/apis/bulk_edit/transforms/transform_operations/v1.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BulkEditOperation } from '../../../../../../application/rule/methods/bulk_edit'; +import { BulkEditRulesRequestBodyV1 } from '../../../../../../../common/routes/rule/apis/bulk_edit'; + +export const transformOperations = ({ + operations, + isSystemAction, +}: { + operations?: BulkEditRulesRequestBodyV1['operations']; + isSystemAction: (connectorId: string) => boolean; +}): BulkEditOperation[] => { + if (operations == null || operations.length === 0) { + return []; + } + + return operations.map((operation) => { + if (operation.field !== 'actions') { + return operation; + } + + const actions = operation.value.map((action) => { + if (isSystemAction(action.id)) { + return { + id: action.id, + params: action.params, + uuid: action.uuid, + }; + } + + return { + id: action.id, + group: action.group ?? 'default', + params: action.params, + uuid: action.uuid, + frequency: action.frequency, + }; + }); + + return { + field: operation.field, + operation: operation.operation, + value: actions, + }; + }); +}; diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.test.ts index 512436475a395..47f05a90f9c7b 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { pick } from 'lodash'; +import { omit, pick } from 'lodash'; import { createRuleRoute } from './create_rule_route'; import { httpServiceMock } from '@kbn/core/server/mocks'; import { licenseStateMock } from '../../../../lib/license_state.mock'; @@ -14,10 +14,10 @@ import { mockHandlerArguments } from '../../../_mock_handler_arguments'; import type { CreateRuleRequestBodyV1 } from '../../../../../common/routes/rule/apis/create'; import { rulesClientMock } from '../../../../rules_client.mock'; import { RuleTypeDisabledError } from '../../../../lib'; -import { AsApiContract } from '../../../lib'; -import { SanitizedRule } from '../../../../types'; +import { RuleAction, RuleSystemAction, SanitizedRule } from '../../../../types'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; const rulesClient = rulesClientMock.create(); @@ -32,6 +32,36 @@ beforeEach(() => { describe('createRuleRoute', () => { const createdAt = new Date(); const updatedAt = new Date(); + const action: RuleAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + uuid: '123-456', + alertsFilter: { + query: { + kql: 'name:test', + dsl: '{"must": {"term": { "name": "test" }}}', + filters: [], + }, + timeframe: { + days: [1], + hours: { start: '08:00', end: '17:00' }, + timezone: 'UTC', + }, + }, + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; const mockedAlert: SanitizedRule<{ bar: boolean }> = { alertTypeId: '1', @@ -43,30 +73,7 @@ describe('createRuleRoute', () => { bar: true, }, throttle: '30s', - actions: [ - { - actionTypeId: 'test', - group: 'default', - id: '2', - params: { - foo: true, - }, - uuid: '123-456', - alertsFilter: { - query: { - kql: 'name:test', - // @ts-expect-error upgrade typescript v4.9.5 - dsl: '{"must": {"term": { "name": "test" }}}', - filters: [], - }, - timeframe: { - days: [1], - hours: { start: '08:00', end: '17:00' }, - timezone: 'UTC', - }, - }, - }, - ], + actions: [action], enabled: true, muteAll: false, createdBy: '', @@ -90,18 +97,21 @@ describe('createRuleRoute', () => { notify_when: mockedAlert.notifyWhen, actions: [ { - group: mockedAlert.actions[0].group, + group: action.group, id: mockedAlert.actions[0].id, params: mockedAlert.actions[0].params, alerts_filter: { - query: { kql: mockedAlert.actions[0].alertsFilter!.query!.kql, filters: [] }, - timeframe: mockedAlert.actions[0].alertsFilter?.timeframe!, + query: { + kql: action.alertsFilter!.query!.kql, + filters: [], + }, + timeframe: action.alertsFilter?.timeframe!, }, }, ], }; - const createResult: AsApiContract> = { + const createResult = { ...ruleToCreate, mute_all: mockedAlert.muteAll, created_by: mockedAlert.createdBy, @@ -120,8 +130,8 @@ describe('createRuleRoute', () => { { ...ruleToCreate.actions[0], alerts_filter: { - query: mockedAlert.actions[0].alertsFilter?.query!, - timeframe: mockedAlert.actions[0].alertsFilter!.timeframe!, + query: action.alertsFilter?.query!, + timeframe: action.alertsFilter!.timeframe!, }, connector_type_id: 'test', uuid: '123-456', @@ -212,6 +222,7 @@ describe('createRuleRoute', () => { "schedule": Object { "interval": "10s", }, + "systemActions": Array [], "tags": Array [ "foo", ], @@ -328,6 +339,7 @@ describe('createRuleRoute', () => { "schedule": Object { "interval": "10s", }, + "systemActions": Array [], "tags": Array [ "foo", ], @@ -445,6 +457,7 @@ describe('createRuleRoute', () => { "schedule": Object { "interval": "10s", }, + "systemActions": Array [], "tags": Array [ "foo", ], @@ -562,6 +575,7 @@ describe('createRuleRoute', () => { "schedule": Object { "interval": "10s", }, + "systemActions": Array [], "tags": Array [ "foo", ], @@ -647,4 +661,176 @@ describe('createRuleRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + describe('actions', () => { + it('passes the system actions correctly to the rules client', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + createRuleRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [_, handler] = router.post.mock.calls[0]; + + rulesClient.create.mockResolvedValueOnce({ + ...mockedAlert, + actions: [action], + systemActions: [systemAction], + }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: { ...ruleToCreate, actions: [action, systemAction] }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesClient.create.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "2", + "params": Object { + "foo": true, + }, + "uuid": "123-456", + }, + ], + "alertTypeId": "1", + "consumer": "bar", + "enabled": true, + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "systemActions": Array [ + Object { + "actionTypeId": "test-2", + "id": "system_action-id", + "params": Object { + "foo": true, + }, + "uuid": "123-456", + }, + ], + "tags": Array [ + "foo", + ], + "throttle": "30s", + }, + "options": Object { + "id": undefined, + }, + }, + ] + `); + }); + + it('transforms the system actions in the response of the rules client correctly', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + createRuleRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [_, handler] = router.post.mock.calls[0]; + + rulesClient.create.mockResolvedValueOnce({ + ...mockedAlert, + actions: [action], + systemActions: [systemAction], + }); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: ruleToCreate, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + alerts_filter: { + query: { dsl: '{"must": {"term": { "name": "test" }}}', filters: [], kql: 'name:test' }, + timeframe: { days: [1], hours: { end: '17:00', start: '08:00' }, timezone: 'UTC' }, + }, + connector_type_id: 'test', + group: 'default', + id: '2', + params: { foo: true }, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { foo: true }, + uuid: '123-456', + }, + ]); + }); + + it('throws an error if the default action does not specifies a group', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); + const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); + const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + createRuleRoute({ + router, + licenseState, + encryptedSavedObjects, + usageCounter: mockUsageCounter, + }); + + const [_, handler] = router.post.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + body: { ...ruleToCreate, actions: [omit(action, 'group')] }, + }, + ['ok'] + ); + + await expect(handler(context, req, res)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Group is not defined in action 2"` + ); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts b/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts index 6b28b64284904..aca112b85d96a 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/create/create_rule_route.ts @@ -26,6 +26,7 @@ import type { RuleParamsV1 } from '../../../../../common/routes/rule/response'; import { Rule } from '../../../../application/rule/types'; import { transformCreateBodyV1 } from './transforms'; import { transformRuleToRuleResponseV1 } from '../../transforms'; +import { validateRequiredGroupInDefaultActions } from '../../../lib/validate_required_group_in_default_actions'; export const createRuleRoute = ({ router, licenseState, usageCounter }: RouteOptions) => { router.post( @@ -40,6 +41,7 @@ export const createRuleRoute = ({ router, licenseState, usageCounter }: RouteOpt router.handleLegacyErrors( verifyAccessAndContext(licenseState, async function (context, req, res) { const rulesClient = (await context.alerting).getRulesClient(); + const actionsClient = (await context.actions).getActionsClient(); // Assert versioned inputs const createRuleData: CreateRuleRequestBodyV1 = req.body; @@ -52,10 +54,19 @@ export const createRuleRoute = ({ router, licenseState, usageCounter }: RouteOpt }); try { + /** + * Throws an error if the group is not defined in default actions + */ + validateRequiredGroupInDefaultActions(createRuleData.actions, (connectorId: string) => + actionsClient.isSystemAction(connectorId) + ); + // TODO (http-versioning): Remove this cast, this enables us to move forward // without fixing all of other solution types const createdRule: Rule = (await rulesClient.create({ - data: transformCreateBodyV1(createRuleData), + data: transformCreateBodyV1(createRuleData, (connectorId: string) => + actionsClient.isSystemAction(connectorId) + ), options: { id: params?.id }, })) as Rule; diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.test.ts new file mode 100644 index 0000000000000..b6165ab910864 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CreateRuleAction, + CreateRuleRequestBodyV1, +} from '../../../../../../../common/routes/rule/apis/create'; +import { transformCreateBody } from './v1'; + +describe('Transform actions V1', () => { + const defaultAction: CreateRuleAction = { + actionTypeId: 'test', + group: 'default', + id: '2', + params: {}, + uuid: '123-456', + use_alert_data_for_template: false, + }; + + const systemAction: CreateRuleAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: {}, + uuid: '123-456', + }; + + const rule: CreateRuleRequestBodyV1<{}> = { + rule_type_id: '1', + consumer: 'bar', + name: 'abc', + schedule: { interval: '10s' }, + tags: ['foo'], + params: {}, + throttle: '30s', + actions: [defaultAction, systemAction], + enabled: true, + notify_when: 'onActionGroupChange', + }; + + describe('transformCreateBody', () => { + const isSystemAction = jest.fn().mockImplementation((id) => id === 'system_action-id'); + + it('should transform the system actions correctly', async () => { + expect(transformCreateBody(rule, isSystemAction)).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "2", + "params": Object {}, + "useAlertDataForTemplate": false, + "uuid": "123-456", + }, + ], + "alertTypeId": "1", + "consumer": "bar", + "enabled": true, + "name": "abc", + "notifyWhen": "onActionGroupChange", + "params": Object {}, + "schedule": Object { + "interval": "10s", + }, + "systemActions": Array [ + Object { + "actionTypeId": "test-2", + "id": "system_action-id", + "params": Object {}, + "uuid": "123-456", + }, + ], + "tags": Array [ + "foo", + ], + "throttle": "30s", + } + `); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts b/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts index 5dea295c40ed7..17edc64b637f4 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts @@ -12,40 +12,70 @@ import type { import type { CreateRuleData } from '../../../../../../application/rule/methods/create'; import type { RuleParams } from '../../../../../../application/rule/types'; -const transformCreateBodyActions = (actions: CreateRuleActionV1[]): CreateRuleData['actions'] => { - if (!actions) return []; - - return actions.map( - ({ - frequency, - alerts_filter: alertsFilter, - use_alert_data_for_template: useAlertDataForTemplate, - ...action - }) => { - return { - group: action.group, - id: action.id, - params: action.params, - actionTypeId: action.actionTypeId, - ...(typeof useAlertDataForTemplate !== 'undefined' ? { useAlertDataForTemplate } : {}), - ...(action.uuid ? { uuid: action.uuid } : {}), - ...(frequency - ? { - frequency: { - summary: frequency.summary, - throttle: frequency.throttle, - notifyWhen: frequency.notify_when, - }, - } - : {}), - ...(alertsFilter ? { alertsFilter } : {}), - }; - } - ); +const transformCreateBodyActions = ( + actions: CreateRuleActionV1[], + isSystemAction: (connectorId: string) => boolean +): CreateRuleData['actions'] => { + const defaultActions: CreateRuleData['actions'] = []; + if (!actions) return defaultActions; + + actions + .filter((action) => !isSystemAction(action.id)) + .forEach( + ({ + frequency, + alerts_filter: alertsFilter, + use_alert_data_for_template: useAlertDataForTemplate, + ...action + }) => { + defaultActions.push({ + group: action.group ?? 'default', + id: action.id, + params: action.params, + actionTypeId: action.actionTypeId, + ...(typeof useAlertDataForTemplate !== 'undefined' ? { useAlertDataForTemplate } : {}), + ...(action.uuid ? { uuid: action.uuid } : {}), + ...(frequency + ? { + frequency: { + summary: frequency.summary, + throttle: frequency.throttle, + notifyWhen: frequency.notify_when, + }, + } + : {}), + ...(alertsFilter ? { alertsFilter } : {}), + }); + } + ); + + return defaultActions; +}; + +const transformCreateBodySystemActions = ( + actions: CreateRuleActionV1[], + isSystemAction: (connectorId: string) => boolean +): CreateRuleData['systemActions'] => { + const defaultActions: CreateRuleData['systemActions'] = []; + if (!actions) return defaultActions; + + actions + .filter((action) => isSystemAction(action.id)) + .forEach((systemAction) => { + defaultActions.push({ + id: systemAction.id, + params: systemAction.params, + actionTypeId: systemAction.actionTypeId, + ...(systemAction.uuid ? { uuid: systemAction.uuid } : {}), + }); + }); + + return defaultActions; }; export const transformCreateBody = ( - createBody: CreateRuleRequestBodyV1 + createBody: CreateRuleRequestBodyV1, + isSystemAction: (connectorId: string) => boolean ): CreateRuleData => { return { name: createBody.name, @@ -56,7 +86,8 @@ export const transformCreateBody = ( ...(createBody.throttle ? { throttle: createBody.throttle } : {}), params: createBody.params, schedule: createBody.schedule, - actions: transformCreateBodyActions(createBody.actions), + actions: transformCreateBodyActions(createBody.actions, isSystemAction), + systemActions: transformCreateBodySystemActions(createBody.actions, isSystemAction), ...(createBody.notify_when ? { notifyWhen: createBody.notify_when } : {}), ...(createBody.alert_delay ? { alertDelay: createBody.alert_delay } : {}), }; diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/resolve/resolve_rule_route.test.ts b/x-pack/plugins/alerting/server/routes/rule/apis/resolve/resolve_rule_route.test.ts index 929dc6ad2ccc1..b11096cd9343b 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/resolve/resolve_rule_route.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/resolve/resolve_rule_route.test.ts @@ -13,7 +13,8 @@ import { verifyApiAccess } from '../../../../lib/license_api_access'; import { mockHandlerArguments } from '../../../_mock_handler_arguments'; import { rulesClientMock } from '../../../../rules_client.mock'; import { ResolvedRule } from '../../../../application/rule/methods/resolve/types'; -import { ResolvedSanitizedRule } from '../../../../../common'; +import { ResolvedSanitizedRule, RuleAction, RuleSystemAction } from '../../../../../common'; +import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; const rulesClient = rulesClientMock.create(); jest.mock('../../../../lib/license_api_access', () => ({ @@ -25,6 +26,26 @@ beforeEach(() => { }); describe('resolveRuleRoute', () => { + const action: RuleAction = { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '123-456', + useAlertDataForTemplate: false, + }; + + const systemAction: RuleSystemAction = { + actionTypeId: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }; + const mockedRule: ResolvedRule<{ bar: boolean; }> = { @@ -36,18 +57,7 @@ describe('resolveRuleRoute', () => { }, createdAt: new Date(), updatedAt: new Date(), - actions: [ - { - group: 'default', - id: '2', - actionTypeId: 'test', - params: { - foo: true, - }, - uuid: '123-456', - useAlertDataForTemplate: false, - }, - ], + actions: [action], consumer: 'bar', name: 'abc', tags: ['foo'], @@ -102,7 +112,7 @@ describe('resolveRuleRoute', () => { params: mockedRule.actions[0].params, connector_type_id: mockedRule.actions[0].actionTypeId, uuid: mockedRule.actions[0].uuid, - use_alert_data_for_template: mockedRule.actions[0].useAlertDataForTemplate, + use_alert_data_for_template: (mockedRule.actions[0] as RuleAction).useAlertDataForTemplate, }, ], outcome: 'aliasMatch', @@ -191,4 +201,56 @@ describe('resolveRuleRoute', () => { expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); }); + + it('transforms the system actions in the response of the rules client correctly', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + const actionsClient = actionsClientMock.create(); + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + + resolveRuleRoute(router, licenseState); + const [_, handler] = router.get.mock.calls[0]; + + // TODO (http-versioning): Remove this cast, this enables us to move forward + // without fixing all of other solution types + rulesClient.resolve.mockResolvedValueOnce({ + ...mockedRule, + actions: [action], + systemActions: [systemAction], + } as ResolvedSanitizedRule); + + const [context, req, res] = mockHandlerArguments( + { rulesClient, actionsClient }, + { + params: { id: '1' }, + }, + ['ok'] + ); + + const routeRes = await handler(context, req, res); + + // @ts-expect-error: body exists + expect(routeRes.body.systemActions).toBeUndefined(); + // @ts-expect-error: body exists + expect(routeRes.body.actions).toEqual([ + { + connector_type_id: 'test', + group: 'default', + id: '2', + params: { + foo: true, + }, + use_alert_data_for_template: false, + uuid: '123-456', + }, + { + connector_type_id: 'test-2', + id: 'system_action-id', + params: { + foo: true, + }, + uuid: '123-456', + }, + ]); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/rule/transforms/index.ts b/x-pack/plugins/alerting/server/routes/rule/transforms/index.ts index c589df53347f3..6e7ba66e752be 100644 --- a/x-pack/plugins/alerting/server/routes/rule/transforms/index.ts +++ b/x-pack/plugins/alerting/server/routes/rule/transforms/index.ts @@ -7,3 +7,5 @@ export { transformRuleToRuleResponse } from './transform_rule_to_rule_response/latest'; export { transformRuleToRuleResponse as transformRuleToRuleResponseV1 } from './transform_rule_to_rule_response/v1'; +export { transformRuleActions } from './transform_rule_to_rule_response/latest'; +export { transformRuleActions as transformRuleActionsV1 } from './transform_rule_to_rule_response/v1'; diff --git a/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/latest.ts b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/latest.ts index 11e717fe2d16d..f0561497b5d17 100644 --- a/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/latest.ts +++ b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/latest.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { transformRuleToRuleResponse } from './v1'; +export { transformRuleToRuleResponse, transformRuleActions } from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.test.ts b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.test.ts new file mode 100644 index 0000000000000..2a5b2e77f313e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RuleAction, RuleSystemAction } from '../../../../../common'; +import { transformRuleToRuleResponse } from './v1'; + +describe('transformRuleToRuleResponse', () => { + const defaultAction: RuleAction = { + id: '1', + uuid: '111', + params: { foo: 'bar' }, + group: 'default', + actionTypeId: '.test', + frequency: { notifyWhen: 'onThrottleInterval', summary: true, throttle: '1h' }, + alertsFilter: { + query: { dsl: '{test:1}', kql: 'test:1s', filters: [] }, + timeframe: { + days: [1, 2, 3], + hours: { end: '15:00', start: '00:00' }, + timezone: 'UTC', + }, + }, + }; + + const systemAction: RuleSystemAction = { + id: '1', + uuid: '111', + params: { foo: 'bar' }, + actionTypeId: '.test', + }; + + const rule = { + id: '3d534c70-582b-11ec-8995-2b1578a3bc5d', + enabled: true, + name: 'stressing index-threshold 37/200', + tags: [], + alertTypeId: '.index-threshold', + consumer: 'alerts', + schedule: { + interval: '1s', + }, + actions: [], + params: {}, + createdBy: 'elastic', + updatedBy: '2889684073', + createdAt: new Date('2023-08-01T09:16:35.368Z'), + updatedAt: new Date('2023-08-01T09:16:35.368Z'), + notifyWhen: 'onActiveAlert' as const, + throttle: null, + apiKey: null, + apiKeyOwner: '2889684073', + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: '52125fb0-5895-11ec-ae69-bb65d1a71b72', + executionStatus: { + status: 'ok' as const, + lastExecutionDate: new Date('2023-08-01T09:16:35.368Z'), + lastDuration: 1194, + }, + revision: 0, + }; + + describe('actions', () => { + it('transforms a default action correctly', () => { + const res = transformRuleToRuleResponse({ ...rule, actions: [defaultAction] }); + expect(res.actions).toEqual([ + { + alerts_filter: { + query: { dsl: '{test:1}', filters: [], kql: 'test:1s' }, + timeframe: { + days: [1, 2, 3], + hours: { end: '15:00', start: '00:00' }, + timezone: 'UTC', + }, + }, + connector_type_id: '.test', + frequency: { notify_when: 'onThrottleInterval', summary: true, throttle: '1h' }, + group: 'default', + id: '1', + params: { foo: 'bar' }, + uuid: '111', + }, + ]); + }); + + it('transforms a system action correctly', () => { + const res = transformRuleToRuleResponse({ ...rule, systemActions: [systemAction] }); + expect(res.actions).toEqual([ + { + id: '1', + uuid: '111', + params: { foo: 'bar' }, + connector_type_id: '.test', + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts index f4fdedfc6f436..30bbf77c45ba3 100644 --- a/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts +++ b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts @@ -38,6 +38,56 @@ const transformMonitoring = (monitoring: Monitoring): MonitoringV1 => { }; }; +export const transformRuleActions = ( + actions: Rule['actions'], + systemActions: Rule['systemActions'] = [] +): RuleResponseV1['actions'] => { + return [ + ...actions.map((action) => { + const { + group, + id, + actionTypeId, + params, + frequency, + uuid, + alertsFilter, + useAlertDataForTemplate, + } = action; + + return { + group, + id, + params, + connector_type_id: actionTypeId, + ...(frequency + ? { + frequency: { + summary: frequency.summary, + notify_when: frequency.notifyWhen, + throttle: frequency.throttle, + }, + } + : {}), + ...(uuid && { uuid }), + ...(alertsFilter && { alerts_filter: alertsFilter }), + ...(useAlertDataForTemplate !== undefined && { + use_alert_data_for_template: useAlertDataForTemplate, + }), + }; + }), + ...systemActions.map((sActions) => { + const { id, actionTypeId, params, uuid } = sActions; + return { + id, + params, + uuid, + connector_type_id: actionTypeId, + }; + }), + ]; +}; + export const transformRuleToRuleResponse = ( rule: Rule ): RuleResponseV1 => ({ @@ -48,37 +98,7 @@ export const transformRuleToRuleResponse = ( rule_type_id: rule.alertTypeId, consumer: rule.consumer, schedule: rule.schedule, - actions: rule.actions.map( - ({ - group, - id, - actionTypeId, - params, - frequency, - uuid, - alertsFilter, - useAlertDataForTemplate, - }) => ({ - group, - id, - params, - connector_type_id: actionTypeId, - ...(typeof useAlertDataForTemplate !== 'undefined' - ? { use_alert_data_for_template: useAlertDataForTemplate } - : {}), - ...(frequency - ? { - frequency: { - summary: frequency.summary, - notify_when: frequency.notifyWhen, - throttle: frequency.throttle, - }, - } - : {}), - ...(uuid && { uuid }), - ...(alertsFilter && { alerts_filter: alertsFilter }), - }) - ), + actions: transformRuleActions(rule.actions, rule.systemActions ?? []), params: rule.params, ...(rule.mapped_params ? { mapped_params: rule.mapped_params } : {}), ...(rule.scheduledTaskId !== undefined ? { scheduled_task_id: rule.scheduledTaskId } : {}), diff --git a/x-pack/plugins/alerting/server/routes/update_rule.test.ts b/x-pack/plugins/alerting/server/routes/update_rule.test.ts index b48e6d72bef3f..9519a8111efbd 100644 --- a/x-pack/plugins/alerting/server/routes/update_rule.test.ts +++ b/x-pack/plugins/alerting/server/routes/update_rule.test.ts @@ -5,18 +5,15 @@ * 2.0. */ -import { pick } from 'lodash'; -import { updateRuleRoute } from './update_rule'; +import { omit, pick } from 'lodash'; +import { UpdateRequestBody, updateRuleRoute } from './update_rule'; import { httpServiceMock } from '@kbn/core/server/mocks'; import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; -import { UpdateOptions } from '../rules_client'; import { rulesClientMock } from '../rules_client.mock'; import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled'; import { RuleNotifyWhen } from '../../common'; -import { AsApiContract } from './lib'; -import { PartialRule } from '../types'; const rulesClient = rulesClientMock.create(); jest.mock('../lib/license_api_access', () => ({ @@ -58,22 +55,37 @@ describe('updateRuleRoute', () => { }, }, ], + systemActions: [ + { + actionTypeId: '.test-system-action', + uuid: '1234-5678', + id: 'system_action-id', + params: {}, + }, + ], notifyWhen: RuleNotifyWhen.CHANGE, alertDelay: { active: 10, }, }; - const updateRequest: AsApiContract['data']> = { + const mockedAction0 = mockedAlert.actions[0]; + + const updateRequest: UpdateRequestBody = { ...pick(mockedAlert, 'name', 'tags', 'schedule', 'params', 'throttle'), notify_when: mockedAlert.notifyWhen, actions: [ { uuid: '1234-5678', - group: mockedAlert.actions[0].group, - id: mockedAlert.actions[0].id, - params: mockedAlert.actions[0].params, - alerts_filter: mockedAlert.actions[0].alertsFilter, + group: mockedAction0.group, + id: mockedAction0.id, + params: mockedAction0.params, + alerts_filter: mockedAction0.alertsFilter, + }, + { + uuid: '1234-5678', + id: 'system_action-id', + params: {}, }, ], alert_delay: { @@ -81,17 +93,36 @@ describe('updateRuleRoute', () => { }, }; - const updateResult: AsApiContract> = { + const updateResult = { ...updateRequest, id: mockedAlert.id, updated_at: mockedAlert.updatedAt, created_at: mockedAlert.createdAt, rule_type_id: mockedAlert.alertTypeId, - actions: mockedAlert.actions.map(({ actionTypeId, alertsFilter, ...rest }) => ({ - ...rest, - connector_type_id: actionTypeId, - alerts_filter: alertsFilter, - })), + actions: [ + { + uuid: '1234-5678', + group: 'default', + id: '2', + connector_type_id: 'test', + params: { + baz: true, + }, + alerts_filter: { + query: { + kql: 'name:test', + dsl: '{"must": {"term": { "name": "test" }}}', + filters: [], + }, + }, + }, + { + connector_type_id: '.test-system-action', + uuid: '1234-5678', + id: 'system_action-id', + params: {}, + }, + ], alert_delay: mockedAlert.alertDelay, }; @@ -153,6 +184,13 @@ describe('updateRuleRoute', () => { "schedule": Object { "interval": "12s", }, + "systemActions": Array [ + Object { + "id": "system_action-id", + "params": Object {}, + "uuid": "1234-5678", + }, + ], "tags": Array [ "foo", ], @@ -241,4 +279,32 @@ describe('updateRuleRoute', () => { expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); }); + + it('throws an error if the default action does not specifies a group', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateRuleRoute(router, licenseState); + + const [config, handler] = router.put.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/alerting/rule/{id}"`); + + rulesClient.update.mockResolvedValueOnce(mockedAlert); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + body: { ...updateRequest, actions: [omit(updateRequest.actions[0], 'group')] }, + }, + ['ok'] + ); + + await expect(handler(context, req, res)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Group is not defined in action 2"` + ); + }); }); diff --git a/x-pack/plugins/alerting/server/routes/update_rule.ts b/x-pack/plugins/alerting/server/routes/update_rule.ts index 9419d84d06341..354cf9c1b65e7 100644 --- a/x-pack/plugins/alerting/server/routes/update_rule.ts +++ b/x-pack/plugins/alerting/server/routes/update_rule.ts @@ -6,18 +6,18 @@ */ import { schema } from '@kbn/config-schema'; +import { TypeOf } from '@kbn/config-schema/src/types/object_type'; import { IRouter } from '@kbn/core/server'; import { ILicenseState, RuleTypeDisabledError, validateDurationSchema } from '../lib'; import { UpdateOptions } from '../rules_client'; import { verifyAccessAndContext, - RewriteResponseCase, - RewriteRequestCase, + AsApiContract, handleDisabledApiKeysError, - rewriteActionsReq, - rewriteActionsRes, actionsSchema, rewriteRuleLastRun, + rewriteActionsReq, + rewriteSystemActionsReq, } from './lib'; import { RuleTypeParams, @@ -26,6 +26,14 @@ import { validateNotifyWhenType, PartialRule, } from '../types'; +import { transformRuleActions } from './rule/transforms'; +import { RuleResponse } from '../../common/routes/rule/response'; +import { validateRequiredGroupInDefaultActions } from './lib/validate_required_group_in_default_actions'; + +export type UpdateRequestBody = TypeOf; +interface RuleUpdateOptionsResult extends Omit, 'data'> { + data: UpdateRequestBody; +} const paramSchema = schema.object({ id: schema.string(), @@ -59,20 +67,26 @@ const bodySchema = schema.object({ ), }); -const rewriteBodyReq: RewriteRequestCase> = (result) => { - const { notify_when: notifyWhen, alert_delay: alertDelay, actions, ...rest } = result.data; +const rewriteBodyReq = ( + result: RuleUpdateOptionsResult, + isSystemAction: (connectorId: string) => boolean +): UpdateOptions => { + const { notify_when: notifyWhen, alert_delay: alertDelay, actions = [], ...rest } = result.data; return { ...result, data: { ...rest, notifyWhen, - actions: rewriteActionsReq(actions), + actions: rewriteActionsReq(actions.filter((action) => !isSystemAction(action.id))), + systemActions: rewriteSystemActionsReq(actions.filter((action) => isSystemAction(action.id))), alertDelay, }, }; }; -const rewriteBodyRes: RewriteResponseCase> = ({ + +const rewriteBodyRes = ({ actions, + systemActions, alertTypeId, scheduledTaskId, createdBy, @@ -91,7 +105,10 @@ const rewriteBodyRes: RewriteResponseCase> = ({ nextRun, alertDelay, ...rest -}) => ({ +}: PartialRule): Omit< + AsApiContract & { actions?: RuleResponse['actions'] }>, + 'actions' +> => ({ ...rest, api_key_owner: apiKeyOwner, created_by: createdBy, @@ -116,7 +133,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ : {}), ...(actions ? { - actions: rewriteActionsRes(actions), + actions: transformRuleActions(actions, systemActions), } : {}), ...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}), @@ -141,10 +158,23 @@ export const updateRuleRoute = ( router.handleLegacyErrors( verifyAccessAndContext(licenseState, async function (context, req, res) { const rulesClient = (await context.alerting).getRulesClient(); + const actionsClient = (await context.actions).getActionsClient(); + const { id } = req.params; const rule = req.body; try { - const alertRes = await rulesClient.update(rewriteBodyReq({ id, data: rule })); + /** + * Throws an error if the group is not defined in default actions + */ + validateRequiredGroupInDefaultActions(rule.actions ?? [], (connectorId: string) => + actionsClient.isSystemAction(connectorId) + ); + + const alertRes = await rulesClient.update( + rewriteBodyReq({ id, data: rule }, (connectorId: string) => + actionsClient.isSystemAction(connectorId) + ) + ); return res.ok({ body: rewriteBodyRes(alertRes), }); diff --git a/x-pack/plugins/alerting/server/rules_client/common/apply_bulk_edit_operation.test.ts b/x-pack/plugins/alerting/server/rules_client/common/apply_bulk_edit_operation.test.ts index 49ed183ceb39d..d88ff3ef2b024 100644 --- a/x-pack/plugins/alerting/server/rules_client/common/apply_bulk_edit_operation.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/common/apply_bulk_edit_operation.test.ts @@ -6,7 +6,7 @@ */ import { applyBulkEditOperation } from './apply_bulk_edit_operation'; -import type { Rule } from '../../types'; +import { Rule } from '../../types'; describe('applyBulkEditOperation', () => { describe('tags operations', () => { @@ -182,41 +182,75 @@ describe('applyBulkEditOperation', () => { const ruleMock = { actions: [{ id: 'mock-action-id', group: 'default', params: {} }], }; + const { modifiedAttributes, isAttributeModified } = applyBulkEditOperation( { field: 'actions', value: [ - { id: 'mock-add-action-id-1', group: 'default', params: {} }, - { id: 'mock-add-action-id-2', group: 'default', params: {} }, + { + id: 'mock-add-action-id-1', + group: 'default', + params: {}, + }, + { + id: 'mock-add-action-id-2', + group: 'default', + params: {}, + }, ], operation: 'add', }, ruleMock ); + expect(modifiedAttributes).toHaveProperty('actions', [ { id: 'mock-action-id', group: 'default', params: {} }, { id: 'mock-add-action-id-1', group: 'default', params: {} }, { id: 'mock-add-action-id-2', group: 'default', params: {} }, ]); + expect(isAttributeModified).toBe(true); }); test('should add action with different params and same id', () => { const ruleMock = { - actions: [{ id: 'mock-action-id', group: 'default', params: { test: 1 } }], + actions: [ + { + id: 'mock-action-id', + group: 'default', + params: { test: 1 }, + }, + ], }; + const { modifiedAttributes, isAttributeModified } = applyBulkEditOperation( { field: 'actions', - value: [{ id: 'mock-action-id', group: 'default', params: { test: 2 } }], + value: [ + { + id: 'mock-action-id', + group: 'default', + params: { test: 2 }, + }, + ], operation: 'add', }, ruleMock ); + expect(modifiedAttributes).toHaveProperty('actions', [ - { id: 'mock-action-id', group: 'default', params: { test: 1 } }, - { id: 'mock-action-id', group: 'default', params: { test: 2 } }, + { + id: 'mock-action-id', + group: 'default', + params: { test: 1 }, + }, + { + id: 'mock-action-id', + group: 'default', + params: { test: 2 }, + }, ]); + expect(isAttributeModified).toBe(true); }); @@ -224,17 +258,30 @@ describe('applyBulkEditOperation', () => { const ruleMock = { actions: [{ id: 'mock-action-id', group: 'default', params: {} }], }; + const { modifiedAttributes, isAttributeModified } = applyBulkEditOperation( { field: 'actions', - value: [{ id: 'mock-rewrite-action-id-1', group: 'default', params: {} }], + value: [ + { + id: 'mock-rewrite-action-id-1', + group: 'default', + params: {}, + }, + ], operation: 'set', }, ruleMock ); + expect(modifiedAttributes).toHaveProperty('actions', [ - { id: 'mock-rewrite-action-id-1', group: 'default', params: {} }, + { + id: 'mock-rewrite-action-id-1', + group: 'default', + params: {}, + }, ]); + expect(isAttributeModified).toBe(true); }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/common/inject_references.ts b/x-pack/plugins/alerting/server/rules_client/common/inject_references.ts index bbf192e4f1cf4..2938c91372325 100644 --- a/x-pack/plugins/alerting/server/rules_client/common/inject_references.ts +++ b/x-pack/plugins/alerting/server/rules_client/common/inject_references.ts @@ -9,7 +9,7 @@ import Boom from '@hapi/boom'; import { omit } from 'lodash'; import { SavedObjectReference, SavedObjectAttributes } from '@kbn/core/server'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; -import { Rule, RawRule, RuleTypeParams } from '../../types'; +import { RawRule, RuleTypeParams } from '../../types'; import { RuleActionAttributes } from '../../data/rule/types'; import { preconfiguredConnectorActionRefPrefix, @@ -45,7 +45,7 @@ export function injectReferencesIntoActions( ...omit(action, 'actionRef'), id: reference.id, }; - }) as Rule['actions']; + }); } export function injectReferencesIntoParams< diff --git a/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts index 1c04ca38bf655..871debb9b4e9c 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts @@ -6,7 +6,7 @@ */ import { addGeneratedActionValues } from './add_generated_action_values'; -import { RuleAction } from '../../../common'; +import { RuleAction, RuleSystemAction } from '../../../common'; import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; @@ -21,6 +21,7 @@ import { AlertingAuthorization } from '../../authorization'; import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; import { ConstructorOptions } from '../rules_client'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; jest.mock('uuid', () => ({ v4: () => '111-222', @@ -62,6 +63,8 @@ describe('addGeneratedActionValues()', () => { getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), }; const mockAction: RuleAction = { @@ -92,24 +95,54 @@ describe('addGeneratedActionValues()', () => { }, }; + const mockSystemAction: RuleSystemAction = { + id: '1', + actionTypeId: 'slack', + params: {}, + }; + test('adds uuid', async () => { - const actionWithGeneratedValues = await addGeneratedActionValues([mockAction], { - ...rulesClientParams, - fieldsToExcludeFromPublicApi: [], - minimumScheduleIntervalInMs: 0, + const actionWithGeneratedValues = await addGeneratedActionValues( + [mockAction], + [mockSystemAction], + { + ...rulesClientParams, + fieldsToExcludeFromPublicApi: [], + minimumScheduleIntervalInMs: 0, + } + ); + + expect(actionWithGeneratedValues.actions[0].uuid).toBe('111-222'); + + expect(actionWithGeneratedValues.systemActions[0]).toEqual({ + actionTypeId: 'slack', + id: '1', + params: {}, + uuid: '111-222', }); - expect(actionWithGeneratedValues[0].uuid).toBe('111-222'); }); test('adds DSL', async () => { - const actionWithGeneratedValues = await addGeneratedActionValues([mockAction], { - ...rulesClientParams, - fieldsToExcludeFromPublicApi: [], - minimumScheduleIntervalInMs: 0, - }); - expect(actionWithGeneratedValues[0].alertsFilter?.query?.dsl).toBe( + const actionWithGeneratedValues = await addGeneratedActionValues( + [mockAction], + [mockSystemAction], + { + ...rulesClientParams, + fieldsToExcludeFromPublicApi: [], + minimumScheduleIntervalInMs: 0, + } + ); + + expect(actionWithGeneratedValues.actions[0].alertsFilter?.query?.dsl).toBe( '{"bool":{"must":[],"filter":[{"bool":{"should":[{"match":{"test":"testValue"}}],"minimum_should_match":1}},{"match_phrase":{"foo":"bar "}}],"should":[],"must_not":[]}}' ); + + expect(actionWithGeneratedValues.systemActions[0]).toEqual({ + actionTypeId: 'slack', + id: '1', + params: {}, + uuid: '111-222', + }); }); test('throws error if KQL is not valid', async () => { @@ -121,6 +154,7 @@ describe('addGeneratedActionValues()', () => { alertsFilter: { query: { kql: 'foo:bar:1', filters: [] } }, }, ], + [mockSystemAction], { ...rulesClientParams, fieldsToExcludeFromPublicApi: [], diff --git a/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.ts b/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.ts index 551c1ab1d2819..d50e773d880a0 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.ts @@ -11,14 +11,20 @@ import Boom from '@hapi/boom'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; import { NormalizedAlertAction, - NormalizedAlertActionWithGeneratedValues, + NormalizedAlertDefaultActionWithGeneratedValues, + NormalizedAlertSystemActionWithGeneratedValues, + NormalizedSystemAction, RulesClientContext, } from '..'; export async function addGeneratedActionValues( actions: NormalizedAlertAction[] = [], + systemActions: NormalizedSystemAction[] = [], context: RulesClientContext -): Promise { +): Promise<{ + actions: NormalizedAlertDefaultActionWithGeneratedValues[]; + systemActions: NormalizedAlertSystemActionWithGeneratedValues[]; +}> { const uiSettingClient = context.uiSettings.asScopedToClient(context.unsecuredSavedObjectsClient); const [allowLeadingWildcards, queryStringOptions, ignoreFilterIfFieldNotInIndex] = await Promise.all([ @@ -31,33 +37,40 @@ export async function addGeneratedActionValues( queryStringOptions, ignoreFilterIfFieldNotInIndex, }; - return actions.map(({ uuid, alertsFilter, ...action }) => { - const generateDSL = (kql: string, filters: Filter[]) => { - try { - return JSON.stringify( - buildEsQuery(undefined, [{ query: kql, language: 'kuery' }], filters, esQueryConfig) - ); - } catch (e) { - throw Boom.badRequest(`Error creating DSL query: invalid KQL`); - } - }; + const generateDSL = (kql: string, filters: Filter[]): string => { + try { + return JSON.stringify( + buildEsQuery(undefined, [{ query: kql, language: 'kuery' }], filters, esQueryConfig) + ); + } catch (e) { + throw Boom.badRequest(`Error creating DSL query: invalid KQL`); + } + }; - return { - ...action, - uuid: uuid || v4(), - ...(alertsFilter - ? { - alertsFilter: { - ...alertsFilter, - query: alertsFilter.query - ? { - ...alertsFilter.query, - dsl: generateDSL(alertsFilter.query.kql, alertsFilter.query.filters), - } - : undefined, - }, - } - : {}), - }; - }); + return { + actions: actions.map((action) => { + const { alertsFilter, uuid, ...restAction } = action; + return { + ...restAction, + uuid: uuid || v4(), + ...(alertsFilter + ? { + alertsFilter: { + ...alertsFilter, + query: alertsFilter.query + ? { + ...alertsFilter.query, + dsl: generateDSL(alertsFilter.query.kql, alertsFilter.query.filters) ?? '', + } + : undefined, + }, + } + : {}), + }; + }), + systemActions: systemActions.map((systemAction) => ({ + ...systemAction, + uuid: systemAction.uuid || v4(), + })), + }; } diff --git a/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts index 44fb6d2e73c36..93b43545b9747 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts @@ -22,6 +22,7 @@ import { RawRule } from '../../types'; import { getBeforeSetup, mockedDateString } from '../tests/lib'; import { createNewAPIKeySet } from './create_new_api_key_set'; import { RulesClientContext } from '../types'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -53,9 +54,11 @@ const rulesClientParams: jest.Mocked = { fieldsToExcludeFromPublicApi: [], isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; const username = 'test'; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.ts b/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.ts index db1dca186aee9..3cd1113a13628 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/denormalize_actions.ts @@ -4,21 +4,24 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { SavedObjectReference } from '@kbn/core/server'; -import { RawRule } from '../../types'; import { preconfiguredConnectorActionRefPrefix, systemConnectorActionRefPrefix, } from '../common/constants'; -import { NormalizedAlertActionWithGeneratedValues, RulesClientContext } from '../types'; +import { + DenormalizedAction, + NormalizedAlertActionWithGeneratedValues, + RulesClientContext, +} from '../types'; export async function denormalizeActions( context: RulesClientContext, alertActions: NormalizedAlertActionWithGeneratedValues[] -): Promise<{ actions: RawRule['actions']; references: SavedObjectReference[] }> { +): Promise<{ actions: DenormalizedAction[]; references: SavedObjectReference[] }> { const references: SavedObjectReference[] = []; - const actions: RawRule['actions'] = []; + const actions: DenormalizedAction[] = []; + if (alertActions.length) { const actionsClient = await context.getActionsClient(); const actionIds = [...new Set(alertActions.map((alertAction) => alertAction.id))]; @@ -29,12 +32,15 @@ export async function denormalizeActions( }); const actionTypeIds = [...new Set(actionResults.map((action) => action.actionTypeId))]; + actionTypeIds.forEach((id) => { // Notify action type usage via "isActionTypeEnabled" function actionsClient.isActionTypeEnabled(id, { notifyUsage: true }); }); + alertActions.forEach(({ id, ...alertAction }, i) => { const actionResultValue = actionResults.find((action) => action.id === id); + if (actionResultValue) { if (actionsClient.isPreconfigured(id)) { actions.push({ @@ -50,11 +56,13 @@ export async function denormalizeActions( }); } else { const actionRef = `action_${i}`; + references.push({ id, name: actionRef, type: 'action', }); + actions.push({ ...alertAction, actionRef, diff --git a/x-pack/plugins/alerting/server/rules_client/lib/extract_references.ts b/x-pack/plugins/alerting/server/rules_client/lib/extract_references.ts index a7525cf9b5b19..9dfca0897ca08 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/extract_references.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/extract_references.ts @@ -6,9 +6,9 @@ */ import { SavedObjectReference } from '@kbn/core/server'; -import { RawRule, RuleTypeParams } from '../../types'; +import { RuleTypeParams } from '../../types'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; -import { NormalizedAlertActionWithGeneratedValues } from '../types'; +import { DenormalizedAction, NormalizedAlertActionWithGeneratedValues } from '../types'; import { extractedSavedObjectParamReferenceNamePrefix } from '../common/constants'; import { RulesClientContext } from '../types'; import { denormalizeActions } from './denormalize_actions'; @@ -22,7 +22,7 @@ export async function extractReferences< ruleActions: NormalizedAlertActionWithGeneratedValues[], ruleParams: Params ): Promise<{ - actions: RawRule['actions']; + actions: DenormalizedAction[]; params: ExtractedParams; references: SavedObjectReference[]; }> { diff --git a/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.ts b/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.ts index ddf1e40728b28..ac337001099bd 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/get_alert_from_raw.ts @@ -23,8 +23,12 @@ import { } from '../../lib'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; import { getActiveScheduledSnoozes } from '../../lib/is_rule_snoozed'; -import { injectReferencesIntoActions, injectReferencesIntoParams } from '../common'; +import { injectReferencesIntoParams } from '../common'; import { RulesClientContext } from '../types'; +import { + transformRawActionsToDomainActions, + transformRawActionsToDomainSystemActions, +} from '../../application/rule/transforms/transform_raw_actions_to_domain_actions'; export interface GetAlertFromRawParams { id: string; @@ -93,6 +97,7 @@ export function getPartialRuleFromRaw( actions, snoozeSchedule, lastRun, + isSnoozedUntil: DoNotUseIsSNoozedUntil, ...partialRawRule }: Partial, references: SavedObjectReference[] | undefined, @@ -118,6 +123,7 @@ export function getPartialRuleFromRaw( }) : null; const includeMonitoring = monitoring && !excludeFromPublicApi; + const rule: PartialRule = { id, notifyWhen, @@ -125,7 +131,24 @@ export function getPartialRuleFromRaw( // we currently only support the Interval Schedule type // Once we support additional types, this type signature will likely change schedule: schedule as IntervalSchedule, - actions: actions ? injectReferencesIntoActions(id, actions, references || []) : [], + actions: actions + ? transformRawActionsToDomainActions({ + ruleId: id, + actions, + references: references || [], + isSystemAction: context.isSystemAction, + omitGeneratedValues, + }) + : [], + systemActions: actions + ? transformRawActionsToDomainSystemActions({ + ruleId: id, + actions, + references: references || [], + isSystemAction: context.isSystemAction, + omitGeneratedValues, + }) + : [], params: injectReferencesIntoParams(id, ruleType, params, references || []) as Params, ...(excludeFromPublicApi ? {} : { snoozeSchedule: snoozeScheduleDates ?? [] }), ...(includeSnoozeData && !excludeFromPublicApi @@ -134,7 +157,7 @@ export function getPartialRuleFromRaw( snoozeSchedule, muteAll: partialRawRule.muteAll ?? false, })?.map((s) => s.id), - isSnoozedUntil, + isSnoozedUntil: isSnoozedUntil as PartialRule['isSnoozedUntil'], } : {}), ...(updatedAt ? { updatedAt: new Date(updatedAt) } : {}), @@ -161,7 +184,9 @@ export function getPartialRuleFromRaw( if (omitGeneratedValues) { if (rule.actions) { - rule.actions = rule.actions.map((ruleAction) => omit(ruleAction, 'alertsFilter.query.dsl')); + rule.actions = rule.actions.map((ruleAction) => { + return omit(ruleAction, 'alertsFilter.query.dsl'); + }); } } diff --git a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.ts b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.ts index 74a56d1964ada..68c6a73b098d6 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/format_legacy_actions.ts @@ -99,7 +99,9 @@ export const legacyGetBulkRuleActionsSavedObject = async ({ legacyRawActions, savedObject.references ) // remove uuid from action, as this uuid is not persistent - .map(({ uuid, ...action }) => action), + .map(({ uuid, ...action }) => ({ + ...action, + })) as RuleAction[], }; } else { logger.error( diff --git a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/migrate_legacy_actions.ts b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/migrate_legacy_actions.ts index 75bcb39e522b9..da14fe60e47fb 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/migrate_legacy_actions.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/siem_legacy_actions/migrate_legacy_actions.ts @@ -7,15 +7,13 @@ import Boom from '@hapi/boom'; import { i18n } from '@kbn/i18n'; - import { AlertConsumers } from '@kbn/rule-data-utils'; - import type { SavedObjectReference } from '@kbn/core/server'; import type { RulesClientContext } from '../..'; import { RawRuleAction, RawRule } from '../../../types'; import { validateActions } from '../validate_actions'; -import { injectReferencesIntoActions } from '../../common'; import { retrieveMigratedLegacyActions } from './retrieve_migrated_legacy_actions'; +import { transformRawActionsToDomainActions } from '../../../application/rule/transforms/transform_raw_actions_to_domain_actions'; type MigrateLegacyActions = ( context: RulesClientContext, @@ -66,7 +64,12 @@ export const migrateLegacyActions: MigrateLegacyActions = async ( // set to undefined to avoid both per-actin and rule level values clashing throttle: undefined, notifyWhen: undefined, - actions: injectReferencesIntoActions(ruleId, legacyActions, legacyActionsReferences), + actions: transformRawActionsToDomainActions({ + ruleId, + actions: legacyActions, + references: legacyActionsReferences, + isSystemAction: context.isSystemAction, + }), }); }; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.test.ts index 74fd3b3291d4e..e565f8b1b51ed 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.test.ts @@ -7,8 +7,8 @@ import { validateActions, ValidateActionsData } from './validate_actions'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; -import { AlertsFilter, RecoveredActionGroup, RuleNotifyWhen } from '../../../common'; -import { RulesClientContext } from '..'; +import { AlertsFilter, RecoveredActionGroup, RuleAction, RuleNotifyWhen } from '../../../common'; +import { NormalizedAlertAction, NormalizedSystemAction, RulesClientContext } from '..'; describe('validateActions', () => { const loggerErrorMock = jest.fn(); @@ -22,7 +22,6 @@ describe('validateActions', () => { isExportable: true, recoveryActionGroup: RecoveredActionGroup, executor: jest.fn(), - category: 'test', producer: 'alerts', cancelAlertsOnRuleTimeout: true, ruleTaskTimeout: '5m', @@ -33,28 +32,35 @@ describe('validateActions', () => { context: 'context', mappings: { fieldMap: { field: { type: 'fieldType', required: false } } }, }, + category: 'test', validLegacyConsumers: [], }; + const defaultAction: NormalizedAlertAction = { + uuid: '111', + group: 'default', + id: '1', + params: {}, + frequency: { + summary: false, + notifyWhen: RuleNotifyWhen.ACTIVE, + throttle: null, + }, + alertsFilter: { + query: { kql: 'test:1', filters: [] }, + timeframe: { days: [1], hours: { start: '10:00', end: '17:00' }, timezone: 'UTC' }, + }, + }; + + const systemAction: NormalizedSystemAction = { + uuid: '111', + id: '1', + params: {}, + }; + const data = { schedule: { interval: '1m' }, - actions: [ - { - uuid: '111', - group: 'default', - id: '1', - params: {}, - frequency: { - summary: false, - notifyWhen: RuleNotifyWhen.ACTIVE, - throttle: null, - }, - alertsFilter: { - query: { kql: 'test:1', filters: [] }, - timeframe: { days: [1], hours: { start: '10:00', end: '17:00' }, timezone: 'UTC' }, - }, - }, - ], + actions: [defaultAction], } as unknown as ValidateActionsData; const context = { @@ -91,6 +97,23 @@ describe('validateActions', () => { ); }); + it('should return error message if actions have duplicated uuid and there is a system action', async () => { + await expect( + validateActions( + context as unknown as RulesClientContext, + ruleType, + { + ...data, + actions: [defaultAction], + systemActions: [systemAction], + }, + false + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + '"Failed to validate actions due to the following error: Actions have duplicated UUIDs"' + ); + }); + it('should return error message if any action have isMissingSecrets', async () => { getBulkMock.mockResolvedValue([{ isMissingSecrets: true, name: 'test name' }]); await expect( @@ -105,7 +128,7 @@ describe('validateActions', () => { validateActions( context as unknown as RulesClientContext, ruleType, - { ...data, actions: [{ ...data.actions[0], group: 'invalid' }] }, + { ...data, actions: [{ ...data.actions[0], group: 'invalid' } as RuleAction] }, false ) ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -144,7 +167,7 @@ describe('validateActions', () => { validateActions( context as unknown as RulesClientContext, ruleType, - { ...data, actions: [{ ...data.actions[0], frequency: undefined }] }, + { ...data, actions: [{ ...data.actions[0], frequency: undefined } as RuleAction] }, false ) ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -163,7 +186,7 @@ describe('validateActions', () => { { ...data.actions[0], frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1s' }, - }, + } as RuleAction, ], }, false @@ -184,7 +207,7 @@ describe('validateActions', () => { { ...data.actions[0], alertsFilter: {} as AlertsFilter, - }, + } as RuleAction, ], }, false @@ -208,7 +231,7 @@ describe('validateActions', () => { query: { kql: 'test:1', filters: [] }, timeframe: { days: [1], hours: { start: '30:00', end: '17:00' }, timezone: 'UTC' }, }, - }, + } as NormalizedAlertAction, ], }, false @@ -255,6 +278,7 @@ describe('validateActions', () => { '"Failed to validate actions due to the following error: Action\'s alertsFilter timeframe has missing fields: days, hours or timezone: 111"' ); }); + it('should return error message if any action has alertsFilter timeframe has invalid days', async () => { await expect( validateActions( diff --git a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts index 5a1e9911b5fcb..4f5be980bae89 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/validate_actions.ts @@ -11,12 +11,13 @@ import { i18n } from '@kbn/i18n'; import { validateHours } from '../../routes/lib/validate_hours'; import { RawRule, RuleNotifyWhen } from '../../types'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; -import { NormalizedAlertAction } from '../types'; +import { NormalizedAlertAction, NormalizedSystemAction } from '../types'; import { RulesClientContext } from '../types'; import { parseDuration } from '../../lib'; export type ValidateActionsData = Pick & { actions: NormalizedAlertAction[]; + systemActions?: NormalizedSystemAction[]; }; export async function validateActions( @@ -25,7 +26,7 @@ export async function validateActions( data: ValidateActionsData, allowMissingConnectorSecrets?: boolean ): Promise { - const { actions, notifyWhen, throttle } = data; + const { actions, notifyWhen, throttle, systemActions = [] } = data; const hasRuleLevelNotifyWhen = typeof notifyWhen !== 'undefined'; const hasRuleLevelThrottle = Boolean(throttle); if (actions.length === 0) { @@ -34,8 +35,9 @@ export async function validateActions( const errors = []; - const uniqueActions = new Set(actions.map((action) => action.uuid)); - if (uniqueActions.size < actions.length) { + const allActions = [...actions, ...systemActions]; + const uniqueActions = new Set(allActions.map((action) => action.uuid)); + if (uniqueActions.size < allActions.length) { errors.push( i18n.translate('xpack.alerting.rulesClient.validateActions.hasDuplicatedUuid', { defaultMessage: 'Actions have duplicated UUIDs', @@ -95,6 +97,7 @@ export async function validateActions( // check for actions using frequency params if the rule has rule-level frequency params defined if (hasRuleLevelNotifyWhen || hasRuleLevelThrottle) { const actionsWithFrequency = actions.filter((action) => Boolean(action.frequency)); + if (actionsWithFrequency.length) { errors.push( i18n.translate('xpack.alerting.rulesClient.validateActions.mixAndMatchFreqParams', { @@ -108,6 +111,7 @@ export async function validateActions( } } else { const actionsWithoutFrequency = actions.filter((action) => !action.frequency); + if (actionsWithoutFrequency.length) { errors.push( i18n.translate('xpack.alerting.rulesClient.validateActions.notAllActionsWithFreq', { diff --git a/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts b/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts index 3d315279c2305..397d49a2751cf 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/bulk_enable.ts @@ -24,7 +24,6 @@ import { getRuleCircuitBreakerErrorMessage } from '../../../common'; import { getAuthorizationFilter, checkAuthorizationAndGetTotal, - getAlertFromRaw, updateMeta, createNewAPIKeySet, migrateLegacyActions, @@ -32,6 +31,12 @@ import { import { RulesClientContext, BulkOperationError, BulkOptions } from '../types'; import { validateScheduleLimit } from '../../application/rule/methods/get_schedule_frequency'; import { RuleAttributes } from '../../data/rule/types'; +import { + transformRuleAttributesToRuleDomain, + transformRuleDomainToRule, +} from '../../application/rule/transforms'; +import type { RuleParams } from '../../application/rule/types'; +import { ruleDomainSchema } from '../../application/rule/schemas'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; const getShouldScheduleTask = async ( @@ -58,8 +63,12 @@ const getShouldScheduleTask = async ( } }; -export const bulkEnableRules = async (context: RulesClientContext, options: BulkOptions) => { +export const bulkEnableRules = async ( + context: RulesClientContext, + options: BulkOptions +) => { const { ids, filter } = getAndValidateCommonBulkOptions(options); + const actionsClient = await context.getActionsClient(); const kueryNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : buildKueryNodeFilter(filter); const authorizationFilter = await getAuthorizationFilter(context, { action: 'ENABLE' }); @@ -90,18 +99,32 @@ export const bulkEnableRules = async (context: RulesClientContext, options: Bulk taskManager: context.taskManager, }); - const updatedRules = rules.map(({ id, attributes, references }) => { - return getAlertFromRaw( - context, - id, - attributes.alertTypeId as string, - attributes as RawRule, - references, - false + const enabledRules = rules.map(({ id, attributes, references }) => { + // TODO (http-versioning): alertTypeId should never be null, but we need to + // fix the type cast from SavedObjectsBulkUpdateObject to SavedObjectsBulkUpdateObject + // when we are doing the bulk disable and this should fix itself + const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId!); + const ruleDomain = transformRuleAttributesToRuleDomain( + attributes as RuleAttributes, + { + id, + logger: context.logger, + ruleType, + references, + omitGeneratedValues: false, + }, + (connectorId: string) => actionsClient.isSystemAction(connectorId) ); + + try { + ruleDomainSchema.validate(ruleDomain); + } catch (e) { + context.logger.warn(`Error validating bulk enabled rule domain object for id: ${id}, ${e}`); + } + return transformRuleDomainToRule(ruleDomain); }); - return { errors, rules: updatedRules, total, taskIdsFailedToBeEnabled }; + return { errors, rules: enabledRules, total, taskIdsFailedToBeEnabled }; }; const bulkEnableRulesWithOCC = async ( diff --git a/x-pack/plugins/alerting/server/rules_client/methods/update.ts b/x-pack/plugins/alerting/server/rules_client/methods/update.ts index 1255173beefe4..1b54dfcb18d94 100644 --- a/x-pack/plugins/alerting/server/rules_client/methods/update.ts +++ b/x-pack/plugins/alerting/server/rules_client/methods/update.ts @@ -22,7 +22,7 @@ import { retryIfConflicts } from '../../lib/retry_if_conflicts'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events'; import { getMappedParams } from '../common/mapped_params_utils'; -import { NormalizedAlertAction, RulesClientContext } from '../types'; +import { NormalizedAlertAction, NormalizedSystemAction, RulesClientContext } from '../types'; import { validateActions, extractReferences, @@ -37,7 +37,10 @@ import { validateScheduleLimit, ValidateScheduleLimitResult, } from '../../application/rule/methods/get_schedule_frequency'; +import { validateSystemActions } from '../../lib/validate_system_actions'; +import { transformRawActionsToDomainActions } from '../../application/rule/transforms'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { transformRawActionsToDomainSystemActions } from '../../application/rule/transforms/transform_raw_actions_to_domain_actions'; type ShouldIncrementRevision = (params?: RuleTypeParams) => boolean; @@ -48,6 +51,7 @@ export interface UpdateOptions { tags: string[]; schedule: IntervalSchedule; actions: NormalizedAlertAction[]; + systemActions?: NormalizedSystemAction[]; params: Params; throttle?: string | null; notifyWhen?: RuleNotifyWhenType | null; @@ -219,16 +223,33 @@ async function updateAlert( currentRule: SavedObject ): Promise> { const { attributes, version } = currentRule; + const { actions: genAction, systemActions: genSystemActions } = await addGeneratedActionValues( + initialData.actions, + initialData.systemActions, + context + ); const data = { ...initialData, - actions: await addGeneratedActionValues(initialData.actions, context), + actions: genAction, + systemActions: genSystemActions, }; - + const actionsClient = await context.getActionsClient(); const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId); + try { + validateActionsSchema(data.actions, data.systemActions); + } catch (error) { + throw Boom.badRequest(`Error validating actions - ${error.message}`); + } + // Validate const validatedAlertTypeParams = validateRuleTypeParams(data.params, ruleType.validate.params); await validateActions(context, ruleType, data, allowMissingConnectorSecrets); + await validateSystemActions({ + actionsClient, + connectorAdapterRegistry: context.connectorAdapterRegistry, + systemActions: data.systemActions, + }); // Throw error if schedule interval is less than the minimum and we are enforcing it const intervalInMs = parseDuration(data.schedule.interval); @@ -241,12 +262,13 @@ async function updateAlert( ); } + const allActions = [...data.actions, ...(data.systemActions ?? [])]; // Extract saved object references for this rule const { references, params: updatedParams, - actions, - } = await extractReferences(context, ruleType, data.actions, validatedAlertTypeParams); + actions: actionsWithRefs, + } = await extractReferences(context, ruleType, allActions, validatedAlertTypeParams); const username = await context.getUserName(); @@ -269,12 +291,13 @@ async function updateAlert( : currentRule.attributes.revision; let updatedObject: SavedObject; + const { systemActions, ...restData } = data; const createAttributes = updateMeta(context, { ...attributes, - ...data, + ...restData, ...apiKeyAttributes, params: updatedParams as RawRule['params'], - actions, + actions: actionsWithRefs, notifyWhen, revision, updatedBy: username, @@ -323,8 +346,7 @@ async function updateAlert( `Rule schedule interval (${data.schedule.interval}) for "${ruleType.id}" rule type with ID "${id}" is less than the minimum value (${context.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` ); } - - return getPartialRuleFromRaw( + const rules = getPartialRuleFromRaw( context, id, ruleType, @@ -333,4 +355,47 @@ async function updateAlert( false, true ); + + return { + ...rules, + actions: transformRawActionsToDomainActions({ + ruleId: id, + references: updatedObject.references, + actions: updatedObject.attributes.actions, + omitGeneratedValues: false, + isSystemAction: (connectorId: string) => actionsClient.isSystemAction(connectorId), + }), + systemActions: transformRawActionsToDomainSystemActions({ + ruleId: id, + references: updatedObject.references, + actions: updatedObject.attributes.actions, + omitGeneratedValues: false, + isSystemAction: (connectorId: string) => actionsClient.isSystemAction(connectorId), + }), + }; } + +/** + * This is a temporary validation to ensure that actions + * contain the expected properties. When the method is migrated to + * use a schema for validation, like the create_rule method, the + * function should be deleted in favor of the schema validation. + */ +const validateActionsSchema = ( + actions: NormalizedAlertAction[], + systemActions: NormalizedSystemAction[] +) => { + for (const action of actions) { + if (!action.group) { + // Simulating kbn-schema error message + throw new Error('[actions.0.group]: expected value of type [string] but got [undefined]'); + } + } + + for (const systemAction of systemActions) { + if ('group' in systemAction || 'frequency' in systemAction || 'alertsFilter' in systemAction) { + // Simulating kbn-schema error message + throw new Error('definition for this key is missing'); + } + } +}; diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 39b4353525f7e..002b0a8d1d0b2 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -103,6 +103,7 @@ export const fieldsToExcludeFromRevisionUpdates: ReadonlySet { @@ -87,6 +94,8 @@ const rulesClientParams: jest.Mocked = { minimumScheduleInterval: { value: '1m', enforce: false }, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), @@ -113,6 +122,7 @@ setGlobalDate(); describe('bulkEnableRules', () => { let rulesClient: RulesClient; + let actionsClient: jest.Mocked; const mockCreatePointInTimeFinderAsInternalUser = ( response = { saved_objects: [disabledRule1, disabledRule2] } @@ -154,11 +164,14 @@ describe('bulkEnableRules', () => { }); mockCreatePointInTimeFinderAsInternalUser(); mockUnsecuredSavedObjectFind(2); + actionsClient = (await rulesClientParams.getActionsClient()) as jest.Mocked; + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action:id'); + rulesClientParams.getActionsClient.mockResolvedValue(actionsClient); }); test('should enable two rule', async () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1, enabledRule2], + saved_objects: [enabledRuleForBulkOps1, enabledRuleForBulkOps2], }); const result = await rulesClient.bulkEnableRules({ filter: 'fake_filter' }); @@ -189,15 +202,48 @@ describe('bulkEnableRules', () => { expect(result).toStrictEqual({ errors: [], - rules: [returnedRule1, returnedRule2], + rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps2], total: 2, taskIdsFailedToBeEnabled: [], }); }); + test('should enable two rule and return right actions', async () => { + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ + saved_objects: [enabledRuleForBulkOpsWithActions1, enabledRuleForBulkOpsWithActions2], + }); + + const result = await rulesClient.bulkDisableRules({ filter: 'fake_filter' }); + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + id: 'id1', + attributes: expect.objectContaining({ + enabled: false, + }), + }), + expect.objectContaining({ + id: 'id2', + attributes: expect.objectContaining({ + enabled: false, + }), + }), + ]), + { overwrite: true } + ); + + expect(result).toStrictEqual({ + errors: [], + rules: [returnedRuleForBulkEnableWithActions1, returnedRuleForBulkEnableWithActions2], + total: 2, + }); + }); + test('should try to enable rules, one successful and one with 500 error', async () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1, savedObjectWith500Error], + saved_objects: [enabledRuleForBulkOps1, savedObjectWith500Error], }); const result = await rulesClient.bulkEnableRules({ filter: 'fake_filter' }); @@ -217,7 +263,7 @@ describe('bulkEnableRules', () => { expect(result).toStrictEqual({ errors: [{ message: 'UPS', rule: { id: 'id2', name: 'fakeName' }, status: 500 }], - rules: [returnedRule1], + rules: [returnedRuleForBulkOps1], total: 2, taskIdsFailedToBeEnabled: [], }); @@ -226,7 +272,7 @@ describe('bulkEnableRules', () => { test('should try to enable rules, one successful and one with 409 error, which will not be deleted with retry', async () => { unsecuredSavedObjectsClient.bulkCreate .mockResolvedValueOnce({ - saved_objects: [enabledRule1, savedObjectWith409Error], + saved_objects: [enabledRuleForBulkOps1, savedObjectWith409Error], }) .mockResolvedValueOnce({ saved_objects: [savedObjectWith409Error], @@ -270,7 +316,7 @@ describe('bulkEnableRules', () => { expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(4); expect(result).toStrictEqual({ errors: [{ message: 'UPS', rule: { id: 'id2', name: 'fakeName' }, status: 409 }], - rules: [returnedRule1], + rules: [returnedRuleForBulkOps1], total: 2, taskIdsFailedToBeEnabled: [], }); @@ -279,10 +325,10 @@ describe('bulkEnableRules', () => { test('should try to enable rules, one successful and one with 409 error, which successfully will be deleted with retry', async () => { unsecuredSavedObjectsClient.bulkCreate .mockResolvedValueOnce({ - saved_objects: [enabledRule1, savedObjectWith409Error], + saved_objects: [enabledRuleForBulkOps1, savedObjectWith409Error], }) .mockResolvedValueOnce({ - saved_objects: [enabledRule2], + saved_objects: [enabledRuleForBulkOps2], }); encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest @@ -311,7 +357,7 @@ describe('bulkEnableRules', () => { expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(2); expect(result).toStrictEqual({ errors: [], - rules: [returnedRule1, returnedRule2], + rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps2], total: 2, taskIdsFailedToBeEnabled: [], }); @@ -386,10 +432,10 @@ describe('bulkEnableRules', () => { test('should skip rule if it is already enabled', async () => { mockCreatePointInTimeFinderAsInternalUser({ - saved_objects: [disabledRule1, enabledRule2], + saved_objects: [disabledRule1, enabledRuleForBulkOps2], }); unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1], + saved_objects: [enabledRuleForBulkOps1], }); const result = await rulesClient.bulkEnableRules({ filter: 'fake_filter' }); @@ -409,7 +455,7 @@ describe('bulkEnableRules', () => { expect(result).toStrictEqual({ errors: [], - rules: [returnedRule1], + rules: [returnedRuleForBulkOps1], total: 2, taskIdsFailedToBeEnabled: [], }); @@ -418,7 +464,7 @@ describe('bulkEnableRules', () => { describe('taskManager', () => { test('should return task id if enabling task failed', async () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1, enabledRule2], + saved_objects: [enabledRuleForBulkOps1, enabledRuleForBulkOps2], }); taskManager.bulkEnable.mockImplementation(async () => ({ tasks: [taskManagerMock.createTask({ id: 'id1' })], @@ -446,7 +492,7 @@ describe('bulkEnableRules', () => { expect(result).toStrictEqual({ errors: [], - rules: [returnedRule1, returnedRule2], + rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps2], total: 2, taskIdsFailedToBeEnabled: ['id2'], }); @@ -454,7 +500,7 @@ describe('bulkEnableRules', () => { test('should not throw an error if taskManager throw an error', async () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1, enabledRule2], + saved_objects: [enabledRuleForBulkOps1, enabledRuleForBulkOps2], }); taskManager.bulkEnable.mockImplementation(() => { throw new Error('UPS'); @@ -469,7 +515,7 @@ describe('bulkEnableRules', () => { expect(result).toStrictEqual({ errors: [], - rules: [returnedRule1, returnedRule2], + rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps2], taskIdsFailedToBeEnabled: ['id1', 'id2'], total: 2, }); @@ -477,7 +523,7 @@ describe('bulkEnableRules', () => { test('should call task manager bulkEnable for two tasks', async () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1, enabledRule2], + saved_objects: [enabledRuleForBulkOps1, enabledRuleForBulkOps2], }); await rulesClient.bulkEnableRules({ filter: 'fake_filter' }); @@ -488,7 +534,7 @@ describe('bulkEnableRules', () => { test('should should call task manager bulkEnable only for one task, if one rule have an error', async () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1, savedObjectWith500Error], + saved_objects: [enabledRuleForBulkOps1, savedObjectWith500Error], }); await rulesClient.bulkEnableRules({ filter: 'fake_filter' }); @@ -499,10 +545,10 @@ describe('bulkEnableRules', () => { test('should skip task if rule is already enabled', async () => { mockCreatePointInTimeFinderAsInternalUser({ - saved_objects: [disabledRule1, enabledRule2], + saved_objects: [disabledRule1, enabledRuleForBulkOps2], }); unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1], + saved_objects: [enabledRuleForBulkOps1], }); taskManager.bulkEnable.mockImplementation( @@ -529,7 +575,7 @@ describe('bulkEnableRules', () => { // One rule gets the task successfully, one rule doesn't so only one task should be scheduled taskManager.get.mockRejectedValueOnce(new Error('Failed to get task!')); unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1, enabledRule2], + saved_objects: [enabledRuleForBulkOps1, enabledRuleForBulkOps2], }); const result = await rulesClient.bulkEnableRules({ ids: ['id1', 'id2'] }); @@ -578,7 +624,7 @@ describe('bulkEnableRules', () => { expect(result).toStrictEqual({ errors: [], - rules: [returnedRule1, returnedRule2], + rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps2], total: 2, taskIdsFailedToBeEnabled: [], }); @@ -602,7 +648,7 @@ describe('bulkEnableRules', () => { }, }); unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1, enabledRule2], + saved_objects: [enabledRuleForBulkOps1, enabledRuleForBulkOps2], }); const result = await rulesClient.bulkEnableRules({ ids: ['id1', 'id2'] }); @@ -650,7 +696,7 @@ describe('bulkEnableRules', () => { expect(result).toStrictEqual({ errors: [], - rules: [returnedRule1, returnedRule2], + rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps2], total: 2, taskIdsFailedToBeEnabled: [], }); @@ -674,7 +720,7 @@ describe('bulkEnableRules', () => { enabled: false, }); unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1, enabledRule2], + saved_objects: [enabledRuleForBulkOps1, enabledRuleForBulkOps2], }); const result = await rulesClient.bulkEnableRules({ ids: ['id1', 'id2'] }); @@ -725,7 +771,7 @@ describe('bulkEnableRules', () => { expect(result).toStrictEqual({ errors: [], - rules: [returnedRule1, returnedRule2], + rules: [returnedRuleForBulkOps1, returnedRuleForBulkOps2], total: 2, taskIdsFailedToBeEnabled: [], }); @@ -737,7 +783,7 @@ describe('bulkEnableRules', () => { test('logs audit event when enabling rules', async () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [enabledRule1], + saved_objects: [enabledRuleForBulkOps1], }); await rulesClient.bulkEnableRules({ filter: 'fake_filter' }); @@ -788,32 +834,38 @@ describe('bulkEnableRules', () => { .mockResolvedValueOnce({ close: jest.fn(), find: function* asyncGenerator() { - yield { saved_objects: [disabledRule1, siemRule1, siemRule2] }; + yield { + saved_objects: [ + disabledRuleForBulkDisable1, + siemRuleForBulkOps1, + siemRuleForBulkOps2, + ], + }; }, }); unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({ - saved_objects: [disabledRule1, siemRule1, siemRule2], + saved_objects: [disabledRuleForBulkDisable1, siemRuleForBulkOps1, siemRuleForBulkOps2], }); await rulesClient.bulkEnableRules({ filter: 'fake_filter' }); expect(migrateLegacyActions).toHaveBeenCalledTimes(3); expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), { - attributes: disabledRule1.attributes, - ruleId: disabledRule1.id, + attributes: disabledRuleForBulkDisable1.attributes, + ruleId: disabledRuleForBulkDisable1.id, actions: [], references: [], }); expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), { attributes: expect.objectContaining({ consumer: AlertConsumers.SIEM }), - ruleId: siemRule1.id, + ruleId: siemRuleForBulkOps1.id, actions: [], references: [], }); expect(migrateLegacyActions).toHaveBeenCalledWith(expect.any(Object), { attributes: expect.objectContaining({ consumer: AlertConsumers.SIEM }), - ruleId: siemRule2.id, + ruleId: siemRuleForBulkOps2.id, actions: [], references: [], }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts index a9673c8aa0f96..a5b691894b77c 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts @@ -26,6 +26,7 @@ import { getBeforeSetup, mockedDateString } from './lib'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { RuleSnooze } from '../../types'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ @@ -71,9 +72,11 @@ const rulesClientParams: jest.Mocked = { eventLogger, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; describe('clearExpiredSnoozes()', () => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/clone.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/clone.test.ts new file mode 100644 index 0000000000000..a23d9b159d79b --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/tests/clone.test.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + savedObjectsClientMock, + loggingSystemMock, + savedObjectsRepositoryMock, + uiSettingsServiceMock, +} from '@kbn/core/server/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { AlertingAuthorization } from '../../authorization/alerting_authorization'; +import { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; +import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { getBeforeSetup } from './lib'; +import { RuleDomain } from '../../application/rule/types'; +import { ConstructorOptions, RulesClient } from '../rules_client'; + +describe('clone', () => { + const taskManager = taskManagerMock.createStart(); + const ruleTypeRegistry = ruleTypeRegistryMock.create(); + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); + const authorization = alertingAuthorizationMock.create(); + const actionsAuthorization = actionsAuthorizationMock.create(); + const auditLogger = auditLoggerMock.create(); + const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + + const kibanaVersion = 'v8.2.0'; + const createAPIKeyMock = jest.fn(); + const isAuthenticationTypeApiKeyMock = jest.fn(); + const getAuthenticationApiKeyMock = jest.fn(); + + const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: createAPIKeyMock, + logger: loggingSystemMock.create().get(), + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: isAuthenticationTypeApiKeyMock, + getAuthenticationAPIKey: getAuthenticationApiKeyMock, + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + uiSettings: uiSettingsServiceMock.createStartContract(), + }; + + let rulesClient: RulesClient; + + beforeEach(() => { + jest.clearAllMocks(); + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); + rulesClient = new RulesClient(rulesClientParams); + }); + + describe('actions', () => { + const rule = { + id: 'test-rule', + type: RULE_SAVED_OBJECT_TYPE, + attributes: { + name: 'My rule', + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + frequency: { + notifyWhen: 'onActiveAlert' as const, + summary: false, + throttle: null, + }, + group: 'default', + params: {}, + actionRef: 'action_0', + actionTypeId: 'test-1', + uuid: '222', + }, + { + params: {}, + actionRef: 'system_action:system_action-id', + actionTypeId: 'test-2', + uuid: '222', + }, + ], + notifyWhen: 'onActiveAlert', + executionStatus: {}, + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }; + + it('transform actions correctly', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(rule); + unsecuredSavedObjectsClient.create.mockResolvedValue(rule); + + const res = await rulesClient.clone('test-rule', { newId: 'test-rule-2' }); + + expect(res.actions).toEqual([ + { + actionTypeId: 'test-1', + frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, + group: 'default', + id: '1', + params: {}, + uuid: '222', + }, + ]); + + expect(res.systemActions).toEqual([ + { actionTypeId: 'test-2', id: 'system_action-id', params: {}, uuid: '222' }, + ]); + }); + + it('clones the actions correctly', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(rule); + unsecuredSavedObjectsClient.create.mockResolvedValue(rule); + + await rulesClient.clone('test-rule', { newId: 'test-rule-2' }); + const results = unsecuredSavedObjectsClient.create.mock.calls[0][1] as RuleDomain; + + expect(results.actions).toMatchInlineSnapshot(` + Array [ + Object { + "actionRef": "action_0", + "actionTypeId": "test-1", + "frequency": Object { + "notifyWhen": "onActiveAlert", + "summary": false, + "throttle": null, + }, + "group": "default", + "params": Object {}, + "uuid": "222", + }, + Object { + "actionRef": "system_action:system_action-id", + "actionTypeId": "test-2", + "params": Object {}, + "uuid": "222", + }, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts index c7988b7644fcd..20ea58cad66ab 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts @@ -25,6 +25,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup } from './lib'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { migrateLegacyActions } from '../lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { @@ -73,9 +74,11 @@ const rulesClientParams: jest.Mocked = { auditLogger, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts index 9e7073da8a18d..9d21fa3477fb9 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts @@ -26,6 +26,7 @@ import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock' import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { migrateLegacyActions } from '../lib'; import { migrateLegacyActionsMock } from '../lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { @@ -75,9 +76,11 @@ const rulesClientParams: jest.Mocked = { eventLogger, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index 580e557e7fdf4..dd48bbe5412c7 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -25,6 +25,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { migrateLegacyActions } from '../lib'; import { migrateLegacyActionsMock } from '../lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { @@ -72,9 +73,11 @@ const rulesClientParams: jest.Mocked = { auditLogger, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; setGlobalDate(); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts index 2281264adaf35..70e9ef57b2e6a 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts @@ -27,6 +27,7 @@ import { RegistryRuleType } from '../../rule_type_registry'; import { schema } from '@kbn/config-schema'; import { enabledRule1, enabledRule2, siemRule1, siemRule2 } from './test_helpers'; import { formatLegacyActions } from '../lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; jest.mock('../lib/siem_legacy_actions/format_legacy_actions', () => { @@ -65,9 +66,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn().mockImplementation((id) => id === 'system_action-id'), }; beforeEach(() => { @@ -100,10 +103,12 @@ describe('find()', () => { validLegacyConsumers: [], }, ]); + beforeEach(() => { authorization.getFindAuthorizationFilter.mockResolvedValue({ ensureRuleTypeIsAuthorized() {}, }); + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ total: 1, per_page: 10, @@ -123,11 +128,13 @@ describe('find()', () => { notifyWhen: 'onActiveAlert', actions: [ { + actionTypeId: 'test-action-id', group: 'default', actionRef: 'action_0', params: { foo: true, }, + uuid: 100, }, ], }, @@ -142,6 +149,7 @@ describe('find()', () => { }, ], }); + ruleTypeRegistry.list.mockReturnValue(listedTypes); authorization.filterByRuleTypeAuthorization.mockResolvedValue( new Set([ @@ -176,11 +184,13 @@ describe('find()', () => { Object { "actions": Array [ Object { + "actionTypeId": "test-action-id", "group": "default", "id": "1", "params": Object { "foo": true, }, + "uuid": 100, }, ], "alertTypeId": "myType", @@ -194,6 +204,7 @@ describe('find()', () => { "interval": "10s", }, "snoozeSchedule": Array [], + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, }, ], @@ -270,18 +281,22 @@ describe('find()', () => { Object { "actions": Array [ Object { + "actionTypeId": undefined, "group": "default", "id": "1", "params": Object { "foo": true, }, + "uuid": undefined, }, Object { + "actionTypeId": undefined, "group": "default", "id": "preconfigured", "params": Object { "foo": true, }, + "uuid": undefined, }, ], "alertTypeId": "myType", @@ -295,6 +310,7 @@ describe('find()', () => { "interval": "10s", }, "snoozeSchedule": Array [], + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, }, ], @@ -361,24 +377,23 @@ describe('find()', () => { }, ], }); + const rulesClient = new RulesClient(rulesClientParams); const result = await rulesClient.find({ options: {} }); + expect(result).toMatchInlineSnapshot(` Object { "data": Array [ Object { "actions": Array [ Object { + "actionTypeId": undefined, "group": "default", "id": "1", "params": Object { "foo": true, }, - }, - Object { - "group": "default", - "id": "system_action-id", - "params": Object {}, + "uuid": undefined, }, ], "alertTypeId": "myType", @@ -392,6 +407,14 @@ describe('find()', () => { "interval": "10s", }, "snoozeSchedule": Array [], + "systemActions": Array [ + Object { + "actionTypeId": undefined, + "id": "system_action-id", + "params": Object {}, + "uuid": undefined, + }, + ], "updatedAt": 2019-02-12T21:01:22.479Z, }, ], @@ -613,11 +636,13 @@ describe('find()', () => { Object { "actions": Array [ Object { + "actionTypeId": undefined, "group": "default", "id": "1", "params": Object { "foo": true, }, + "uuid": undefined, }, ], "alertTypeId": "myType", @@ -631,16 +656,19 @@ describe('find()', () => { "interval": "10s", }, "snoozeSchedule": Array [], + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, }, Object { "actions": Array [ Object { + "actionTypeId": undefined, "group": "default", "id": "1", "params": Object { "foo": true, }, + "uuid": undefined, }, ], "alertTypeId": "123", @@ -655,6 +683,7 @@ describe('find()', () => { "interval": "20s", }, "snoozeSchedule": Array [], + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, }, ], @@ -881,6 +910,7 @@ describe('find()', () => { "params": undefined, "schedule": undefined, "snoozeSchedule": Array [], + "systemActions": Array [], "tags": Array [ "myTag", ], diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts index e377c334d0b1f..1f136a0c181b3 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts @@ -24,6 +24,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; import { formatLegacyActions } from '../lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; jest.mock('../lib/siem_legacy_actions/format_legacy_actions', () => { @@ -62,9 +63,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { @@ -112,11 +115,13 @@ describe('get()', () => { Object { "actions": Array [ Object { + "actionTypeId": undefined, "group": "default", "id": "1", "params": Object { "foo": true, }, + "uuid": undefined, }, ], "alertTypeId": "123", @@ -130,6 +135,7 @@ describe('get()', () => { "interval": "10s", }, "snoozeSchedule": Array [], + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -186,18 +192,22 @@ describe('get()', () => { Object { "actions": Array [ Object { + "actionTypeId": undefined, "group": "default", "id": "1", "params": Object { "foo": true, }, + "uuid": undefined, }, Object { + "actionTypeId": undefined, "group": "default", "id": "preconfigured", "params": Object { "foo": true, }, + "uuid": undefined, }, ], "alertTypeId": "123", @@ -211,6 +221,7 @@ describe('get()', () => { "interval": "10s", }, "snoozeSchedule": Array [], + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -265,16 +276,13 @@ describe('get()', () => { Object { "actions": Array [ Object { + "actionTypeId": undefined, "group": "default", "id": "1", "params": Object { "foo": true, }, - }, - Object { - "group": "default", - "id": "system_action-id", - "params": Object {}, + "uuid": undefined, }, ], "alertTypeId": "123", @@ -288,6 +296,14 @@ describe('get()', () => { "interval": "10s", }, "snoozeSchedule": Array [], + "systemActions": Array [ + Object { + "actionTypeId": undefined, + "id": "system_action-id", + "params": Object {}, + "uuid": undefined, + }, + ], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -377,11 +393,13 @@ describe('get()', () => { Object { "actions": Array [ Object { + "actionTypeId": undefined, "group": "default", "id": "1", "params": Object { "foo": true, }, + "uuid": undefined, }, ], "alertTypeId": "123", @@ -396,6 +414,7 @@ describe('get()', () => { "interval": "10s", }, "snoozeSchedule": Array [], + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts index 761aca1972dd5..0cf207500fdb7 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts @@ -26,6 +26,7 @@ import { SavedObject } from '@kbn/core/server'; import { RawRule } from '../../types'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; const taskManager = taskManagerMock.createStart(); @@ -61,9 +62,11 @@ const rulesClientParams: jest.Mocked = { auditLogger, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts index dc20013b2ad29..94db9bd1c9629 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts @@ -21,6 +21,7 @@ import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { AlertingAuthorization } from '../../authorization/alerting_authorization'; import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { getBeforeSetup } from './lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; @@ -54,9 +55,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts index 0dcc87889b8c4..60769bc20cd6d 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts @@ -26,6 +26,7 @@ import { SavedObject } from '@kbn/core/server'; import { EventsFactory } from '../../lib/alert_summary_from_event_log.test'; import { RawRule } from '../../types'; import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; const taskManager = taskManagerMock.createStart(); @@ -59,9 +60,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts index 82e07adea061a..f90f5835dbcd4 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -27,6 +27,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; import { getExecutionLogAggregation } from '../../lib/get_execution_log_aggregation'; import { fromKueryExpression } from '@kbn/es-query'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; const taskManager = taskManagerMock.createStart(); @@ -62,9 +63,11 @@ const rulesClientParams: jest.Mocked = { auditLogger, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/lib.ts b/x-pack/plugins/alerting/server/rules_client/tests/lib.ts index 994d87320b0b6..386cfe8f41eec 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/lib.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/lib.ts @@ -132,4 +132,6 @@ export function getBeforeSetup( rulesClientParams.getEventLogClient.mockResolvedValue( eventLogClient ?? eventLogClientMock.create() ); + + rulesClientParams.isSystemAction.mockImplementation((id) => id === 'system_action-id'); } diff --git a/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts index a9dd69f1a0e9f..e6f3978987c53 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts @@ -25,6 +25,7 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { getBeforeSetup } from './lib'; import { RecoveredActionGroup } from '../../../common'; import { RegistryRuleType } from '../../rule_type_registry'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -56,9 +57,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts index fcafeea9a640c..7911735644403 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts @@ -21,6 +21,7 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; const taskManager = taskManagerMock.createStart(); @@ -53,9 +54,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts index 9c06960ee91ca..36bba7e734748 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts @@ -21,6 +21,7 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; const taskManager = taskManagerMock.createStart(); @@ -53,9 +54,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts index dcf28f3d03290..bab353225b28b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts @@ -24,6 +24,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; import { formatLegacyActions } from '../lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; jest.mock('../lib/siem_legacy_actions/format_legacy_actions', () => { @@ -62,9 +63,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { @@ -121,11 +124,13 @@ describe('resolve()', () => { Object { "actions": Array [ Object { + "actionTypeId": undefined, "group": "default", "id": "1", "params": Object { "foo": true, }, + "uuid": undefined, }, ], "alertTypeId": "123", @@ -145,6 +150,7 @@ describe('resolve()', () => { "interval": "10s", }, "snoozeSchedule": Array [], + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -301,11 +307,13 @@ describe('resolve()', () => { Object { "actions": Array [ Object { + "actionTypeId": undefined, "group": "default", "id": "1", "params": Object { "foo": true, }, + "uuid": undefined, }, ], "alertTypeId": "123", @@ -326,6 +334,7 @@ describe('resolve()', () => { "interval": "10s", }, "snoozeSchedule": Array [], + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -440,6 +449,106 @@ describe('resolve()', () => { ); }); + test('resolves a rule with actions using system connectors', async () => { + const rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ + saved_object: { + id: '1', + type: RULE_SAVED_OBJECT_TYPE, + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + { + group: 'default', + actionRef: 'system_action:system_action-id', + params: {}, + }, + ], + notifyWhen: 'onActiveAlert', + executionStatus: { + status: 'ok', + last_execution_date: new Date().toISOString(), + last_duration: 10, + }, + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }, + outcome: 'aliasMatch', + alias_target_id: '2', + }); + + const result = await rulesClient.resolve({ id: '1' }); + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": undefined, + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + "uuid": undefined, + }, + ], + "alertTypeId": "123", + "alias_target_id": "2", + "createdAt": 2019-02-12T21:01:22.479Z, + "executionStatus": Object { + "lastExecutionDate": 2019-02-12T21:01:22.479Z, + "status": "ok", + }, + "id": "1", + "notifyWhen": "onActiveAlert", + "outcome": "aliasMatch", + "params": Object { + "bar": true, + }, + "schedule": Object { + "interval": "10s", + }, + "snoozeSchedule": Array [], + "systemActions": Array [ + Object { + "actionTypeId": undefined, + "id": "system_action-id", + "params": Object {}, + "uuid": undefined, + }, + ], + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + + expect(unsecuredSavedObjectsClient.resolve).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.resolve.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alert", + "1", + undefined, + ] + `); + }); + describe('authorization', () => { beforeEach(() => { unsecuredSavedObjectsClient.resolve.mockResolvedValueOnce({ diff --git a/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts index 2874759f98d1e..653e7dd807c8a 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts @@ -22,6 +22,7 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; const taskManager = taskManagerMock.createStart(); @@ -55,9 +56,11 @@ const rulesClientParams: jest.Mocked = { auditLogger, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; setGlobalDate(); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/test_helpers.ts b/x-pack/plugins/alerting/server/rules_client/tests/test_helpers.ts index f060187775a8e..869b38f1762ac 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/test_helpers.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/test_helpers.ts @@ -148,6 +148,122 @@ export const enabledRuleForBulkOps2 = { }, }; +export const enabledRuleForBulkOpsWithActions1 = { + ...defaultRuleForBulkDelete, + attributes: { + ...defaultRuleForBulkDelete.attributes, + enabled: true, + scheduledTaskId: 'id1', + apiKey: Buffer.from('123:abc').toString('base64'), + actions: [ + { + uuid: '1', + id: 'system_action:id', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + id: 'system_action:id', + name: '1', + type: 'action', + }, + ], +}; + +export const enabledRuleForBulkOpsWithActions2 = { + ...defaultRuleForBulkDelete, + id: 'id2', + attributes: { + ...defaultRuleForBulkDelete.attributes, + enabled: true, + scheduledTaskId: 'id2', + apiKey: Buffer.from('321:abc').toString('base64'), + actions: [ + { + uuid: '2', + id: 'default_action:id', + group: 'default', + actionTypeId: '2', + actionRef: '2', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + id: 'default_action:id', + name: '2', + type: 'action', + }, + ], +}; + +export const disabledRuleForBulkOpsWithActions1 = { + ...defaultRuleForBulkDelete, + attributes: { + ...defaultRuleForBulkDelete.attributes, + enabled: false, + scheduledTaskId: 'id1', + apiKey: Buffer.from('123:abc').toString('base64'), + actions: [ + { + uuid: '1', + id: 'system_action:id', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + id: 'system_action:id', + name: '1', + type: 'action', + }, + ], +}; + +export const disabledRuleForBulkOpsWithActions2 = { + ...defaultRuleForBulkDelete, + id: 'id2', + attributes: { + ...defaultRuleForBulkDelete.attributes, + enabled: false, + scheduledTaskId: 'id2', + apiKey: Buffer.from('321:abc').toString('base64'), + actions: [ + { + uuid: '2', + id: 'default_action:id', + group: 'default', + actionTypeId: '2', + actionRef: '2', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + id: 'default_action:id', + name: '2', + type: 'action', + }, + ], +}; + export const enabledRuleForBulkOps3 = { ...defaultRuleForBulkDelete, id: 'id3', @@ -304,7 +420,7 @@ export const returnedRule2 = { snoozeSchedule: [], }; -export const returnedRuleForBulkDelete1 = { +export const returnedRuleForBulkOps1 = { actions: [], alertTypeId: 'fakeType', consumer: 'fakeConsumer', @@ -322,6 +438,7 @@ export const returnedRuleForBulkDelete1 = { }, scheduledTaskId: 'id1', snoozeSchedule: [], + systemActions: [], tags: ['ups'], params: { param: 1 }, muteAll: false, @@ -329,7 +446,7 @@ export const returnedRuleForBulkDelete1 = { revision: 1, }; -export const returnedRuleForBulkDelete2 = { +export const returnedRuleForBulkOps2 = { actions: [], alertTypeId: 'fakeType', consumer: 'fakeConsumer', @@ -347,6 +464,7 @@ export const returnedRuleForBulkDelete2 = { }, scheduledTaskId: 'id2', snoozeSchedule: [], + systemActions: [], tags: ['ups'], params: { param: 1 }, muteAll: false, @@ -354,7 +472,7 @@ export const returnedRuleForBulkDelete2 = { revision: 1, }; -export const returnedRuleForBulkDelete3 = { +export const returnedRuleForBulkOps3 = { actions: [], alertTypeId: 'fakeType', apiKeyCreatedByUser: true, @@ -373,6 +491,7 @@ export const returnedRuleForBulkDelete3 = { }, scheduledTaskId: 'id3', snoozeSchedule: [], + systemActions: [], tags: ['ups'], params: { param: 1 }, muteAll: false, @@ -381,15 +500,73 @@ export const returnedRuleForBulkDelete3 = { }; export const returnedRuleForBulkDisable1 = { - ...returnedRuleForBulkDelete1, + ...returnedRuleForBulkOps1, enabled: false, }; export const returnedRuleForBulkDisable2 = { - ...returnedRuleForBulkDelete2, + ...returnedRuleForBulkOps2, enabled: false, }; +export const returnedRuleForBulkDisableWithActions1 = { + ...returnedRuleForBulkDisable1, + systemActions: [ + { + actionTypeId: '1', + id: 'system_action:id', + params: { + foo: true, + }, + uuid: '1', + }, + ], +}; + +export const returnedRuleForBulkDisableWithActions2 = { + ...returnedRuleForBulkDisable2, + actions: [ + { + actionTypeId: '2', + group: 'default', + id: 'default_action:id', + params: { + foo: true, + }, + uuid: '2', + }, + ], +}; + +export const returnedRuleForBulkEnableWithActions1 = { + ...returnedRuleForBulkOps1, + systemActions: [ + { + actionTypeId: '1', + id: 'system_action:id', + params: { + foo: true, + }, + uuid: '1', + }, + ], +}; + +export const returnedRuleForBulkEnableWithActions2 = { + ...returnedRuleForBulkOps2, + actions: [ + { + actionTypeId: '2', + group: 'default', + id: 'default_action:id', + params: { + foo: true, + }, + uuid: '2', + }, + ], +}; + export const returnedDisabledRule1 = { ...returnedRule1, enabled: false, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts index d999a7604e9b9..20eae2a147dda 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts @@ -21,6 +21,7 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; const taskManager = taskManagerMock.createStart(); @@ -53,9 +54,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts index 35ba9b37bfff1..8275e0a88d8ba 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts @@ -21,6 +21,7 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; const taskManager = taskManagerMock.createStart(); @@ -53,9 +54,11 @@ const rulesClientParams: jest.Mocked = { kibanaVersion, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index 7384ab467a8e9..d66b17b5ff6b6 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -29,7 +29,9 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { migrateLegacyActions } from '../lib'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { RuleDomain } from '../../application/rule/types'; jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -92,6 +94,8 @@ const rulesClientParams: jest.Mocked = { minimumScheduleInterval: { value: '1m', enforce: false }, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), @@ -107,6 +111,7 @@ setGlobalDate(); describe('update()', () => { let rulesClient: RulesClient; let actionsClient: jest.Mocked; + const existingAlert = { id: '1', type: RULE_SAVED_OBJECT_TYPE, @@ -352,6 +357,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, Object { "actionTypeId": "test", @@ -360,6 +366,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, Object { "actionTypeId": "test2", @@ -368,6 +375,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, ], "alertDelay": Object { @@ -385,6 +393,7 @@ describe('update()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -550,10 +559,8 @@ describe('update()', () => { isSystemAction: false, }, ]); - actionsClient.isPreconfigured.mockReset(); - actionsClient.isPreconfigured.mockReturnValueOnce(false); - actionsClient.isPreconfigured.mockReturnValueOnce(true); - actionsClient.isPreconfigured.mockReturnValueOnce(true); + actionsClient.isPreconfigured.mockImplementation((id: string) => id === 'preconfigured'); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: RULE_SAVED_OBJECT_TYPE, @@ -714,6 +721,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, Object { "actionTypeId": "test", @@ -722,6 +730,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, Object { "actionTypeId": "test", @@ -730,6 +739,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, ], "createdAt": 2019-02-12T21:01:22.479Z, @@ -744,6 +754,7 @@ describe('update()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -755,10 +766,10 @@ describe('update()', () => { } ); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); - expect(actionsClient.isPreconfigured).toHaveBeenCalledTimes(3); + expect(actionsClient.isPreconfigured).toHaveBeenCalled(); }); - test('should update a rule with some system actions', async () => { + test('should update a rule with system actions', async () => { actionsClient.getBulk.mockReset(); actionsClient.getBulk.mockResolvedValue([ { @@ -806,10 +817,9 @@ describe('update()', () => { isSystemAction: true, }, ]); - actionsClient.isSystemAction.mockReset(); - actionsClient.isSystemAction.mockReturnValueOnce(false); - actionsClient.isSystemAction.mockReturnValueOnce(true); - actionsClient.isSystemAction.mockReturnValueOnce(true); + + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: RULE_SAVED_OBJECT_TYPE, @@ -829,13 +839,6 @@ describe('update()', () => { }, }, { - group: 'default', - actionRef: 'system_action:system_action-id', - actionTypeId: 'test', - params: {}, - }, - { - group: 'custom', actionRef: 'system_action:system_action-id', actionTypeId: 'test', params: {}, @@ -860,6 +863,7 @@ describe('update()', () => { }, ], }); + const result = await rulesClient.update({ id: '1', data: { @@ -879,13 +883,9 @@ describe('update()', () => { foo: true, }, }, + ], + systemActions: [ { - group: 'default', - id: 'system_action-id', - params: {}, - }, - { - group: 'custom', id: 'system_action-id', params: {}, }, @@ -908,19 +908,11 @@ describe('update()', () => { uuid: '106', }, { - group: 'default', actionRef: 'system_action:system_action-id', actionTypeId: 'test', params: {}, uuid: '107', }, - { - group: 'custom', - actionRef: 'system_action:system_action-id', - actionTypeId: 'test', - params: {}, - uuid: '108', - }, ], alertTypeId: 'myType', apiKey: null, @@ -958,18 +950,7 @@ describe('update()', () => { "params": Object { "foo": true, }, - }, - Object { - "actionTypeId": "test", - "group": "default", - "id": "system_action-id", - "params": Object {}, - }, - Object { - "actionTypeId": "test", - "group": "custom", - "id": "system_action-id", - "params": Object {}, + "uuid": undefined, }, ], "createdAt": 2019-02-12T21:01:22.479Z, @@ -984,9 +965,18 @@ describe('update()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [ + Object { + "actionTypeId": "test", + "id": "system_action-id", + "params": Object {}, + "uuid": undefined, + }, + ], "updatedAt": 2019-02-12T21:01:22.479Z, } `); + expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith( RULE_SAVED_OBJECT_TYPE, '1', @@ -994,8 +984,9 @@ describe('update()', () => { namespace: 'default', } ); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); - expect(actionsClient.isSystemAction).toHaveBeenCalledTimes(3); + expect(actionsClient.isSystemAction).toHaveBeenCalled(); }); test('should call useSavedObjectReferences.extractReferences and useSavedObjectReferences.injectReferences if defined for rule type', async () => { @@ -1112,7 +1103,7 @@ describe('update()', () => { actionTypeId: 'test', group: 'default', params: { foo: true }, - uuid: '109', + uuid: '108', }, ], alertTypeId: 'myType', @@ -1161,6 +1152,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, ], "createdAt": 2019-02-12T21:01:22.479Z, @@ -1176,6 +1168,7 @@ describe('update()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -1253,6 +1246,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, ], "apiKey": "MTIzOmFiYw==", @@ -1268,6 +1262,7 @@ describe('update()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -1292,7 +1287,7 @@ describe('update()', () => { "params": Object { "foo": true, }, - "uuid": "110", + "uuid": "109", }, ], "alertTypeId": "myType", @@ -1414,6 +1409,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, ], "apiKey": null, @@ -1429,6 +1425,7 @@ describe('update()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -1445,7 +1442,7 @@ describe('update()', () => { "params": Object { "foo": true, }, - "uuid": "111", + "uuid": "110", }, ], "alertTypeId": "myType", @@ -1689,6 +1686,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onActionGroupChange', @@ -1834,6 +1832,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onThrottleInterval', @@ -1846,6 +1845,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onThrottleInterval', @@ -1858,6 +1858,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onThrottleInterval', @@ -1896,6 +1897,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onActionGroupChange', @@ -2029,6 +2031,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onActionGroupChange', @@ -2064,6 +2067,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onActionGroupChange', @@ -2101,6 +2105,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onActionGroupChange', @@ -2113,6 +2118,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onActionGroupChange', @@ -2147,6 +2153,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onActionGroupChange', @@ -2231,6 +2238,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onActionGroupChange', @@ -2279,6 +2287,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onActionGroupChange', @@ -2395,10 +2404,8 @@ describe('update()', () => { isSystemAction: false, }, ]); - actionsClient.isPreconfigured.mockReset(); - actionsClient.isPreconfigured.mockReturnValueOnce(false); - actionsClient.isPreconfigured.mockReturnValueOnce(true); - actionsClient.isPreconfigured.mockReturnValueOnce(true); + actionsClient.isPreconfigured.mockImplementation((id: string) => id === 'preconfigured'); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: RULE_SAVED_OBJECT_TYPE, @@ -2473,7 +2480,7 @@ describe('update()', () => { params: { foo: true, }, - uuid: '147', + uuid: '146', }, ], alertTypeId: 'myType', @@ -2512,6 +2519,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, ], "createdAt": 2019-02-12T21:01:22.479Z, @@ -2526,6 +2534,7 @@ describe('update()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -2537,7 +2546,7 @@ describe('update()', () => { } ); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); - expect(actionsClient.isPreconfigured).toHaveBeenCalledTimes(1); + expect(actionsClient.isPreconfigured).toHaveBeenCalled(); }); test('logs warning when creating with an interval less than the minimum configured one when enforce = false', async () => { @@ -2988,6 +2997,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { notifyWhen: 'onActiveAlert', throttle: null, @@ -3001,6 +3011,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { notifyWhen: 'onActiveAlert', throttle: null, @@ -3028,7 +3039,7 @@ describe('update()', () => { frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null }, group: 'default', params: { foo: true }, - uuid: '154', + uuid: '153', }, ], alertTypeId: 'myType', @@ -3198,6 +3209,7 @@ describe('update()', () => { "params": Object { "foo": true, }, + "uuid": undefined, }, ], "apiKey": "MTIzOmFiYw==", @@ -3214,6 +3226,7 @@ describe('update()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "systemActions": Array [], "updatedAt": 2019-02-12T21:01:22.479Z, } `); @@ -3232,7 +3245,7 @@ describe('update()', () => { "params": Object { "foo": true, }, - "uuid": "155", + "uuid": "154", }, ], "alertTypeId": "myType", @@ -3302,6 +3315,7 @@ describe('update()', () => { params: { foo: true, }, + frequency: { summary: false, notifyWhen: 'onActionGroupChange', @@ -3321,4 +3335,507 @@ describe('update()', () => { expect.any(Object) ); }); + + describe('actions', () => { + beforeEach(() => { + actionsClient.isSystemAction.mockImplementation((id: string) => id === 'system_action-id'); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: RULE_SAVED_OBJECT_TYPE, + attributes: { + enabled: true, + schedule: { interval: '1m' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: 'test', + params: {}, + }, + ], + notifyWhen: 'onActiveAlert', + revision: 1, + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + { + name: 'param:soRef_0', + type: 'someSavedObjectType', + id: '9', + }, + ], + }); + + actionsClient.getBulk.mockResolvedValue([ + { + id: '1', + actionTypeId: 'test', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'email connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: '2', + actionTypeId: 'test2', + config: { + from: 'me@me.com', + hasAuth: false, + host: 'hello', + port: 22, + secure: null, + service: null, + }, + isMissingSecrets: false, + name: 'another email connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: false, + }, + { + id: 'system_action-id', + actionTypeId: 'test', + config: {}, + isMissingSecrets: false, + name: 'system action connector', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, + ]); + }); + + test('update a rule with system actions and default actions', async () => { + const result = await rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + systemActions: [ + { + id: 'system_action-id', + params: {}, + }, + ], + }, + }); + + expect(unsecuredSavedObjectsClient.create).toHaveBeenNthCalledWith( + 1, + RULE_SAVED_OBJECT_TYPE, + { + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '156', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: 'test', + params: {}, + uuid: '157', + }, + ], + alertTypeId: 'myType', + apiKey: null, + apiKeyOwner: null, + apiKeyCreatedByUser: null, + consumer: 'myApp', + enabled: true, + meta: { versionApiKeyLastmodified: 'v7.10.0' }, + name: 'abc', + notifyWhen: 'onActiveAlert', + params: { bar: true }, + revision: 1, + schedule: { interval: '1m' }, + scheduledTaskId: 'task-123', + tags: ['foo'], + throttle: null, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + }, + { + id: '1', + overwrite: true, + references: [{ id: '1', name: 'action_0', type: 'action' }], + version: '123', + } + ); + + expect(result).toMatchInlineSnapshot(` + Object { + "actions": Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + "uuid": undefined, + }, + ], + "createdAt": 2019-02-12T21:01:22.479Z, + "enabled": true, + "id": "1", + "notifyWhen": "onActiveAlert", + "params": Object { + "bar": true, + }, + "revision": 1, + "schedule": Object { + "interval": "1m", + }, + "scheduledTaskId": "task-123", + "systemActions": Array [ + Object { + "actionTypeId": "test", + "id": "system_action-id", + "params": Object {}, + "uuid": undefined, + }, + ], + "updatedAt": 2019-02-12T21:01:22.479Z, + } + `); + + expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith( + RULE_SAVED_OBJECT_TYPE, + '1', + { + namespace: 'default', + } + ); + + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); + expect(actionsClient.isSystemAction).toHaveBeenCalled(); + }); + + test('should construct the refs correctly and persist the actions to ES correctly', async () => { + await rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + systemActions: [ + { + id: 'system_action-id', + params: {}, + }, + ], + }, + }); + + const rule = unsecuredSavedObjectsClient.create.mock.calls[0][1] as RuleDomain; + + expect(rule.actions).toEqual([ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + uuid: '158', + }, + { + actionRef: 'system_action:system_action-id', + actionTypeId: 'test', + params: {}, + uuid: '159', + }, + ]); + }); + + test('should transforms the actions from ES correctly', async () => { + const result = await rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + { + group: 'default', + id: '1', + params: { + foo: true, + }, + }, + ], + systemActions: [ + { + id: 'system_action-id', + params: {}, + }, + ], + }, + }); + + expect(result.actions).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": "test", + "group": "default", + "id": "1", + "params": Object { + "foo": true, + }, + "uuid": undefined, + }, + ] + `); + + expect(result.systemActions).toMatchInlineSnapshot(` + Array [ + Object { + "actionTypeId": "test", + "id": "system_action-id", + "params": Object {}, + "uuid": undefined, + }, + ] + `); + }); + + test('should throw an error if the system action does not exist', async () => { + await expect(() => + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [], + systemActions: [ + { + id: 'fake-system-action', + params: {}, + }, + ], + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Action fake-system-action is not a system action]`); + }); + + test('should throw an error if the system action contains the group', async () => { + await expect(() => + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [], + systemActions: [ + { + id: 'system_action-id', + params: {}, + // @ts-expect-error: testing validation + group: 'default', + }, + ], + }, + }) + ).rejects.toMatchInlineSnapshot( + `[Error: Error validating actions - definition for this key is missing]` + ); + }); + + test('should throw an error if the system action contains the frequency', async () => { + await expect(() => + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [], + systemActions: [ + { + id: 'system_action-id', + params: {}, + // @ts-expect-error: testing validation + frequency: { + summary: false, + notifyWhen: 'onActionGroupChange', + throttle: null, + }, + }, + ], + }, + }) + ).rejects.toMatchInlineSnapshot( + `[Error: Error validating actions - definition for this key is missing]` + ); + }); + + test('should throw an error if the system action contains the alertsFilter', async () => { + await expect(() => + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [], + systemActions: [ + { + id: 'system_action-id', + params: {}, + // @ts-expect-error: testing validation + alertsFilter: { + query: { kql: 'test:1', filters: [] }, + }, + }, + ], + }, + }) + ).rejects.toMatchInlineSnapshot( + `[Error: Error validating actions - definition for this key is missing]` + ); + }); + + test('should throw an error if the same system action is used twice', async () => { + await expect(() => + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [], + systemActions: [ + { + id: 'system_action-id', + params: {}, + }, + { + id: 'system_action-id', + params: {}, + }, + ], + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Cannot use the same system action twice]`); + }); + + test('should throw an error if the default action does not contain the group', async () => { + await expect(() => + rulesClient.update({ + id: '1', + data: { + schedule: { interval: '1m' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + notifyWhen: 'onActiveAlert', + actions: [ + // @ts-expect-error: testing validation + { + id: 'action-id-1', + params: {}, + }, + ], + }, + }) + ).rejects.toMatchInlineSnapshot( + `[Error: Error validating actions - [actions.0.group]: expected value of type [string] but got [undefined]]` + ); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts index b0b943cd78f68..66881241021ef 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts @@ -22,6 +22,7 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ @@ -60,9 +61,11 @@ const rulesClientParams: jest.Mocked = { auditLogger, isAuthenticationTypeAPIKey: jest.fn(), getAuthenticationAPIKey: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), + isSystemAction: jest.fn(), }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/rules_client/types.ts b/x-pack/plugins/alerting/server/rules_client/types.ts index 6b3abf6fe3a38..6ca3b69967002 100644 --- a/x-pack/plugins/alerting/server/rules_client/types.ts +++ b/x-pack/plugins/alerting/server/rules_client/types.ts @@ -22,17 +22,20 @@ import { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; import { IEventLogClient, IEventLogger } from '@kbn/event-log-plugin/server'; import { AuditLogger } from '@kbn/security-plugin/server'; +import { DistributiveOmit } from '@elastic/eui'; import { RegistryRuleType } from '../rule_type_registry'; import { RuleTypeRegistry, - RuleAction, IntervalSchedule, SanitizedRule, RuleSnoozeSchedule, RawRuleAlertsFilter, + RuleSystemAction, + RuleAction, } from '../types'; import { AlertingAuthorization } from '../authorization'; import { AlertingRulesConfig } from '../config'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; import { GetAlertIndicesAlias } from '../lib'; import { AlertsService } from '../alerts_service'; @@ -77,21 +80,33 @@ export interface RulesClientContext { readonly fieldsToExcludeFromPublicApi: Array; readonly isAuthenticationTypeAPIKey: () => boolean; readonly getAuthenticationAPIKey: (name: string) => CreateAPIKeyResult; + readonly connectorAdapterRegistry: ConnectorAdapterRegistry; readonly getAlertIndicesAlias: GetAlertIndicesAlias; readonly alertsService: AlertsService | null; + readonly isSystemAction: (actionId: string) => boolean; readonly uiSettings: UiSettingsServiceStart; } -export type NormalizedAlertAction = Omit; +export type NormalizedAlertAction = DistributiveOmit; +export type NormalizedSystemAction = Omit; -export type NormalizedAlertActionWithGeneratedValues = Omit< - NormalizedAlertAction, - 'uuid' | 'alertsFilter' +export type NormalizedAlertDefaultActionWithGeneratedValues = Omit< + RuleAction, + 'uuid' | 'alertsFilter' | 'actionTypeId' > & { uuid: string; alertsFilter?: RawRuleAlertsFilter; }; +export type NormalizedAlertSystemActionWithGeneratedValues = Omit< + RuleSystemAction, + 'uuid' | 'actionTypeId' +> & { uuid: string }; + +export type NormalizedAlertActionWithGeneratedValues = + | NormalizedAlertDefaultActionWithGeneratedValues + | NormalizedAlertSystemActionWithGeneratedValues; + export interface RegistryAlertTypeWithAuth extends RegistryRuleType { authorizedConsumers: string[]; } @@ -127,7 +142,6 @@ export interface IndexType { [key: string]: unknown; } -// TODO: remove once all mute endpoints have been migrated to RuleMuteAlertOptions export interface MuteOptions extends IndexType { alertId: string; alertInstanceId: string; @@ -166,3 +180,11 @@ export interface RuleBulkOperationAggregation { }>; }; } + +export type DenormalizedAction = DistributiveOmit< + NormalizedAlertActionWithGeneratedValues, + 'id' +> & { + actionRef: string; + actionTypeId: string; +}; diff --git a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts index d94e402a4e528..2df24f879e982 100644 --- a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts @@ -25,6 +25,7 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { RetryForConflictsAttempts } from './lib/retry_if_conflicts'; import { TaskStatus } from '@kbn/task-manager-plugin/server/task'; import { RecoveredActionGroup } from '../common'; +import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from './saved_objects'; jest.mock('./application/rule/methods/get_schedule_frequency', () => ({ @@ -70,6 +71,8 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + isSystemAction: jest.fn(), uiSettings: uiSettingsServiceMock.createStartContract(), }; diff --git a/x-pack/plugins/alerting/server/rules_client_factory.test.ts b/x-pack/plugins/alerting/server/rules_client_factory.test.ts index a0b83ca344688..b02321c5da1d1 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.test.ts @@ -27,6 +27,7 @@ import { AlertingAuthorization } from './authorization'; import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import { mockRouter } from '@kbn/core-http-router-server-mocks'; +import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from './saved_objects'; jest.mock('./rules_client'); @@ -48,8 +49,6 @@ const rulesClientFactoryParams: jest.Mocked = { ruleTypeRegistry: ruleTypeRegistryMock.create(), getSpaceId: jest.fn(), spaceIdToNamespace: jest.fn(), - getAlertIndicesAlias: jest.fn(), - alertsService: null, maxScheduledPerMinute: 10000, minimumScheduleInterval: { value: '1m', enforce: false }, internalSavedObjectsRepository, @@ -59,7 +58,10 @@ const rulesClientFactoryParams: jest.Mocked = { kibanaVersion: '7.10.0', authorization: alertingAuthorizationClientFactory as unknown as AlertingAuthorizationClientFactory, + connectorAdapterRegistry: new ConnectorAdapterRegistry(), uiSettings: uiSettingsServiceMock.createStartContract(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, }; const actionsAuthorization = actionsAuthorizationMock.create(); @@ -119,6 +121,8 @@ test('creates a rules client with proper constructor arguments when security is minimumScheduleInterval: { value: '1m', enforce: false }, isAuthenticationTypeAPIKey: expect.any(Function), getAuthenticationAPIKey: expect.any(Function), + connectorAdapterRegistry: expect.any(ConnectorAdapterRegistry), + isSystemAction: expect.any(Function), getAlertIndicesAlias: expect.any(Function), alertsService: null, uiSettings: rulesClientFactoryParams.uiSettings, @@ -164,6 +168,8 @@ test('creates a rules client with proper constructor arguments', async () => { minimumScheduleInterval: { value: '1m', enforce: false }, isAuthenticationTypeAPIKey: expect.any(Function), getAuthenticationAPIKey: expect.any(Function), + connectorAdapterRegistry: expect.any(ConnectorAdapterRegistry), + isSystemAction: expect.any(Function), getAlertIndicesAlias: expect.any(Function), alertsService: null, uiSettings: rulesClientFactoryParams.uiSettings, diff --git a/x-pack/plugins/alerting/server/rules_client_factory.ts b/x-pack/plugins/alerting/server/rules_client_factory.ts index 66ee93a1f4aeb..d5402355930fa 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.ts @@ -29,6 +29,7 @@ import { AlertingAuthorizationClientFactory } from './alerting_authorization_cli import { AlertingRulesConfig } from './config'; import { GetAlertIndicesAlias } from './lib'; import { AlertsService } from './alerts_service/alerts_service'; +import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from './saved_objects'; export interface RulesClientFactoryOpts { logger: Logger; @@ -49,6 +50,7 @@ export interface RulesClientFactoryOpts { maxScheduledPerMinute: AlertingRulesConfig['maxScheduledPerMinute']; getAlertIndicesAlias: GetAlertIndicesAlias; alertsService: AlertsService | null; + connectorAdapterRegistry: ConnectorAdapterRegistry; uiSettings: CoreStart['uiSettings']; } @@ -72,6 +74,7 @@ export class RulesClientFactory { private maxScheduledPerMinute!: AlertingRulesConfig['maxScheduledPerMinute']; private getAlertIndicesAlias!: GetAlertIndicesAlias; private alertsService!: AlertsService | null; + private connectorAdapterRegistry!: ConnectorAdapterRegistry; private uiSettings!: CoreStart['uiSettings']; public initialize(options: RulesClientFactoryOpts) { @@ -97,6 +100,7 @@ export class RulesClientFactory { this.maxScheduledPerMinute = options.maxScheduledPerMinute; this.getAlertIndicesAlias = options.getAlertIndicesAlias; this.alertsService = options.alertsService; + this.connectorAdapterRegistry = options.connectorAdapterRegistry; this.uiSettings = options.uiSettings; } @@ -128,6 +132,7 @@ export class RulesClientFactory { auditLogger: securityPluginSetup?.audit.asScoped(request), getAlertIndicesAlias: this.getAlertIndicesAlias, alertsService: this.alertsService, + connectorAdapterRegistry: this.connectorAdapterRegistry, uiSettings: this.uiSettings, async getUserName() { @@ -187,6 +192,9 @@ export class RulesClientFactory { } return { apiKeysEnabled: false }; }, + isSystemAction(actionId: string) { + return actions.isSystemActionConnector(actionId); + }, }); } } diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/7.16/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/7.16/index.ts index 3984f3e5fb96e..992fe8bfa17a9 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/7.16/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/7.16/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { SavedObjectAttribute, SavedObjectReference } from '@kbn/core-saved-objects-server'; +import { SavedObjectReference } from '@kbn/core-saved-objects-server'; import { SavedObjectUnsanitizedDoc } from '@kbn/core-saved-objects-server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { isString } from 'lodash/fp'; @@ -34,15 +34,13 @@ function getRemovePreconfiguredConnectorsFromReferencesFn( } function getCorrespondingAction( - actions: SavedObjectAttribute, + actions: RawRuleAction | RawRuleAction[], connectorRef: string ): RawRuleAction | null { if (!Array.isArray(actions)) { return null; } else { - return actions.find( - (action) => (action as RawRuleAction)?.actionRef === connectorRef - ) as RawRuleAction; + return actions.find((action) => action.actionRef === connectorRef) ?? null; } } diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/8.3/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/8.3/index.ts index 833971a71dbbe..ba083773e5a9a 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/8.3/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/8.3/index.ts @@ -61,8 +61,12 @@ function removeInternalTags( }; } +interface ConvertSnoozes extends RawRule { + snoozeEndTime?: string; +} + function convertSnoozes( - doc: SavedObjectUnsanitizedDoc + doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc { const { attributes: { snoozeEndTime }, diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts b/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts index b94fb907d8275..613feffa062d1 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations/index.ts @@ -16,6 +16,7 @@ import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-p import { MigrateFunctionsObject, MigrateFunction } from '@kbn/kibana-utils-plugin/common'; import { mergeSavedObjectMigrationMaps } from '@kbn/core/server'; import { isSerializedSearchSource, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { Serializable } from '@kbn/utility-types'; import { RawRule } from '../../types'; import { getMigrations7100 } from './7.10'; import { getMigrations7110, getMigrations7112 } from './7.11'; @@ -111,8 +112,7 @@ function mapSearchSourceMigrationFunc( migrateSerializedSearchSourceFields: MigrateFunction ): MigrateFunction { return (doc) => { - const _doc = doc as { attributes: RawRule }; - + const _doc = doc as { attributes: { params: { searchConfiguration: Serializable } } }; const serializedSearchSource = _doc.attributes.params.searchConfiguration; if (isSerializedSearchSource(serializedSearchSource)) { diff --git a/x-pack/plugins/alerting/server/saved_objects/partially_update_rule.ts b/x-pack/plugins/alerting/server/saved_objects/partially_update_rule.ts index 59b934a824e11..2665845a1110f 100644 --- a/x-pack/plugins/alerting/server/saved_objects/partially_update_rule.ts +++ b/x-pack/plugins/alerting/server/saved_objects/partially_update_rule.ts @@ -59,12 +59,7 @@ export async function partiallyUpdateRule( ); try { - await savedObjectsClient.update( - RULE_SAVED_OBJECT_TYPE, - id, - attributeUpdates, - updateOptions - ); + await savedObjectsClient.update(RULE_SAVED_OBJECT_TYPE, id, attributeUpdates, updateOptions); } catch (err) { if (options?.ignore404 && SavedObjectsErrorHelpers.isNotFoundError(err)) { return; diff --git a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rule/v1.ts b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rule/v1.ts index e0641e9b275ea..caa5f47a959f1 100644 --- a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rule/v1.ts +++ b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rule/v1.ts @@ -194,7 +194,7 @@ const rawRuleAlertsFilterSchema = schema.object({ const rawRuleActionSchema = schema.object({ uuid: schema.maybe(schema.string()), - group: schema.string(), + group: schema.maybe(schema.string()), actionRef: schema.string(), actionTypeId: schema.string(), params: schema.recordOf(schema.string(), schema.any()), diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts index 56a0bbaa7d774..5f5a97c842e33 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts @@ -33,6 +33,7 @@ import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import sinon from 'sinon'; import { mockAAD } from './fixtures'; import { schema } from '@kbn/config-schema'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; import { alertsClientMock } from '../alerts_client/alerts_client.mock'; import { ExecutionResponseType } from '@kbn/actions-plugin/server/create_execute_function'; import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; @@ -127,6 +128,7 @@ const defaultExecutionParams = { }, }, actionsPlugin: mockActionsPlugin, + connectorAdapterRegistry: new ConnectorAdapterRegistry(), } as unknown as TaskRunnerContext, apiKey, ruleConsumer: 'rule-consumer', @@ -2387,4 +2389,270 @@ describe('Execution Handler', () => { `); }); }); + + describe('System actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockActionsPlugin.isSystemActionConnector.mockReturnValue(true); + }); + + test('triggers system actions with summarization per rule run', async () => { + const actionsParams = { myParams: 'test' }; + + alertsClient.getSummarizedAlerts.mockResolvedValue({ + new: { + count: 1, + data: [mockAAD], + }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }); + + const executorParams = generateExecutionParams({ + rule: { + ...defaultExecutionParams.rule, + systemActions: [ + { + id: '1', + actionTypeId: '.test-system-action', + params: actionsParams, + uui: 'test', + }, + ], + }, + }); + + const buildActionParams = jest.fn().mockReturnValue({ ...actionsParams, foo: 'bar' }); + + executorParams.taskRunnerContext.connectorAdapterRegistry.register({ + connectorTypeId: '.test-system-action', + ruleActionParamsSchema: schema.object({}), + buildActionParams, + }); + + executorParams.actionsClient.isSystemAction.mockReturnValue(true); + executorParams.taskRunnerContext.kibanaBaseUrl = 'https://example.com'; + + const executionHandler = new ExecutionHandler(generateExecutionParams(executorParams)); + + const res = await executionHandler.run(generateAlert({ id: 1 })); + + /** + * Verifies that system actions are not throttled + */ + expect(res).toEqual({ throttledSummaryActions: {} }); + + /** + * Verifies that system actions + * work only with summarized alerts + */ + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + executionUuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + ruleId: '1', + spaceId: 'test1', + excludedAlertInstanceIds: [], + alertsFilter: undefined, + }); + + expect(buildActionParams).toHaveBeenCalledWith({ + alerts: { + all: { + count: 1, + data: [mockAAD], + }, + new: { + count: 1, + data: [mockAAD], + }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }, + params: actionsParams, + rule: { + id: rule.id, + name: rule.name, + tags: rule.tags, + }, + ruleUrl: + 'https://example.com/s/test1/app/management/insightsAndAlerting/triggersActions/rule/1', + spaceId: 'test1', + }); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "actionTypeId": ".test-system-action", + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "foo": "bar", + "myParams": "test", + }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); + + expect(alertingEventLogger.logAction).toBeCalledWith({ + alertSummary: { new: 1, ongoing: 0, recovered: 0 }, + id: '1', + typeId: '.test-system-action', + }); + }); + + test('does not execute if the connector adapter is not configured', async () => { + const actionsParams = { myParams: 'test' }; + + alertsClient.getSummarizedAlerts.mockResolvedValue({ + new: { + count: 1, + data: [mockAAD], + }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }); + + const executorParams = generateExecutionParams({ + rule: { + ...defaultExecutionParams.rule, + systemActions: [ + { + id: 'action-id', + actionTypeId: '.connector-adapter-not-exists', + params: actionsParams, + uui: 'test', + }, + ], + }, + }); + + const buildActionParams = jest.fn().mockReturnValue({ ...actionsParams, foo: 'bar' }); + + executorParams.actionsClient.isSystemAction.mockReturnValue(true); + executorParams.taskRunnerContext.kibanaBaseUrl = 'https://example.com'; + + const executionHandler = new ExecutionHandler(generateExecutionParams(executorParams)); + + const res = await executionHandler.run(generateAlert({ id: 1 })); + + /** + * Verifies that system actions are not throttled + */ + expect(res).toEqual({ throttledSummaryActions: {} }); + + /** + * Verifies that system actions + * work only with summarized alerts + */ + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + executionUuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + ruleId: '1', + spaceId: 'test1', + excludedAlertInstanceIds: [], + alertsFilter: undefined, + }); + + expect(buildActionParams).not.toHaveBeenCalledWith(); + expect(actionsClient.ephemeralEnqueuedExecution).not.toHaveBeenCalled(); + expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); + expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); + expect(executorParams.logger.warn).toHaveBeenCalledWith( + 'Rule "1" skipped scheduling system action "action-id" because no connector adapter is configured' + ); + }); + + test('do not execute if the rule type does not support summarized alerts', async () => { + const actionsParams = { myParams: 'test' }; + + const executorParams = generateExecutionParams({ + ruleType: { + ...ruleType, + alerts: undefined, + }, + rule: { + ...defaultExecutionParams.rule, + systemActions: [ + { + id: 'action-id', + actionTypeId: '.test-system-action', + params: actionsParams, + uui: 'test', + }, + ], + }, + }); + + const buildActionParams = jest.fn().mockReturnValue({ ...actionsParams, foo: 'bar' }); + + executorParams.actionsClient.isSystemAction.mockReturnValue(true); + executorParams.taskRunnerContext.kibanaBaseUrl = 'https://example.com'; + + const executionHandler = new ExecutionHandler(generateExecutionParams(executorParams)); + + const res = await executionHandler.run(generateAlert({ id: 1 })); + + expect(res).toEqual({ throttledSummaryActions: {} }); + expect(buildActionParams).not.toHaveBeenCalled(); + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(actionsClient.ephemeralEnqueuedExecution).not.toHaveBeenCalled(); + expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); + expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); + }); + + test('do not execute system actions if the rule type does not support summarized alerts', async () => { + const actionsParams = { myParams: 'test' }; + + const executorParams = generateExecutionParams({ + rule: { + ...defaultExecutionParams.rule, + systemActions: [ + { + id: '1', + actionTypeId: '.test-system-action', + params: actionsParams, + uui: 'test', + }, + ], + }, + ruleType: { + ...defaultExecutionParams.ruleType, + alerts: undefined, + }, + }); + + const buildActionParams = jest.fn().mockReturnValue({ ...actionsParams, foo: 'bar' }); + + executorParams.actionsClient.isSystemAction.mockReturnValue(true); + executorParams.taskRunnerContext.kibanaBaseUrl = 'https://example.com'; + + const executionHandler = new ExecutionHandler(generateExecutionParams(executorParams)); + + await executionHandler.run(generateAlert({ id: 1 })); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(buildActionParams).not.toHaveBeenCalled(); + expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); + expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts index 23fb063384b3e..f794133c69dc7 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -44,6 +44,7 @@ import { SanitizedRule, RuleAlertData, RuleNotifyWhen, + RuleSystemAction, } from '../../common'; import { generateActionHash, @@ -55,6 +56,7 @@ import { isSummaryActionThrottled, } from './rule_action_helper'; import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { ConnectorAdapter } from '../connector_adapters/types'; enum Reasons { MUTED = 'muted', @@ -74,6 +76,35 @@ interface LogAction { }; } +interface RunSummarizedActionArgs { + action: RuleAction; + summarizedAlerts: CombinedSummarizedAlerts; + spaceId: string; + bulkActions: EnqueueExecutionOptions[]; +} + +interface RunSystemActionArgs { + action: RuleSystemAction; + connectorAdapter: ConnectorAdapter; + summarizedAlerts: CombinedSummarizedAlerts; + rule: SanitizedRule; + spaceId: string; + bulkActions: EnqueueExecutionOptions[]; +} + +interface RunActionArgs< + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + action: RuleAction; + alert: Alert; + ruleId: string; + spaceId: string; + bulkActions: EnqueueExecutionOptions[]; +} + export interface RunResult { throttledSummaryActions: ThrottledActions; } @@ -186,233 +217,330 @@ export class ExecutionHandler< }); const executables = await this.generateExecutables(alerts, throttledSummaryActions); - if (!!executables.length) { - const { - CHUNK_SIZE, - logger, - alertingEventLogger, - ruleRunMetricsStore, - taskRunnerContext: { actionsConfigMap, actionsPlugin }, - taskInstance: { - params: { spaceId, alertId: ruleId }, - }, - } = this; + if (executables.length === 0) { + return { throttledSummaryActions }; + } - const logActions: Record = {}; - const bulkActions: EnqueueExecutionOptions[] = []; - let bulkActionsResponse: ExecutionResponseItem[] = []; + const { + CHUNK_SIZE, + logger, + alertingEventLogger, + ruleRunMetricsStore, + taskRunnerContext: { actionsConfigMap }, + taskInstance: { + params: { spaceId, alertId: ruleId }, + }, + } = this; - this.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); + const logActions: Record = {}; + const bulkActions: EnqueueExecutionOptions[] = []; + let bulkActionsResponse: ExecutionResponseItem[] = []; - for (const { action, alert, summarizedAlerts } of executables) { - const { actionTypeId } = action; - const actionGroup = action.group as ActionGroupIds; + this.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); - ruleRunMetricsStore.incrementNumberOfGeneratedActionsByConnectorType(actionTypeId); + for (const { action, alert, summarizedAlerts } of executables) { + const { actionTypeId } = action; - if (ruleRunMetricsStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId, - status: ActionsCompletion.PARTIAL, - }); + ruleRunMetricsStore.incrementNumberOfGeneratedActionsByConnectorType(actionTypeId); + if (ruleRunMetricsStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + logger.debug( + `Rule "${this.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` + ); + break; + } + + if ( + ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType({ + actionTypeId, + actionsConfigMap, + }) + ) { + if (!ruleRunMetricsStore.hasConnectorTypeReachedTheLimit(actionTypeId)) { logger.debug( - `Rule "${this.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` + `Rule "${this.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${actionTypeId} has been reached.` ); - break; } + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + continue; + } - if ( - ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType({ - actionTypeId, - actionsConfigMap, - }) - ) { - if (!ruleRunMetricsStore.hasConnectorTypeReachedTheLimit(actionTypeId)) { - logger.debug( - `Rule "${this.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${actionTypeId} has been reached.` - ); - } - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - continue; + if (!this.isExecutableAction(action)) { + this.logger.warn( + `Rule "${this.taskInstance.params.alertId}" skipped scheduling action "${action.id}" because it is disabled` + ); + continue; + } + + ruleRunMetricsStore.incrementNumberOfTriggeredActions(); + ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId); + + if (!this.isSystemAction(action) && summarizedAlerts) { + const defaultAction = action as RuleAction; + if (isActionOnInterval(action)) { + throttledSummaryActions[defaultAction.uuid!] = { date: new Date().toISOString() }; } - if (!this.isExecutableAction(action)) { + logActions[defaultAction.id] = await this.runSummarizedAction({ + action, + summarizedAlerts, + spaceId, + bulkActions, + }); + } else if (summarizedAlerts && this.isSystemAction(action)) { + const hasConnectorAdapter = this.taskRunnerContext.connectorAdapterRegistry.has( + action.actionTypeId + ); + /** + * System actions without an adapter + * cannot be executed + * + */ + if (!hasConnectorAdapter) { this.logger.warn( - `Rule "${this.taskInstance.params.alertId}" skipped scheduling action "${action.id}" because it is disabled` + `Rule "${this.taskInstance.params.alertId}" skipped scheduling system action "${action.id}" because no connector adapter is configured` ); + continue; } - ruleRunMetricsStore.incrementNumberOfTriggeredActions(); - ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId); - - if (summarizedAlerts) { - const { start, end } = getSummaryActionTimeBounds( - action, - this.rule.schedule, - this.previousStartedAt - ); - const ruleUrl = this.buildRuleUrl(spaceId, start, end); - const actionToRun = { - ...action, - params: injectActionParams({ - actionTypeId, - ruleUrl, - ruleName: this.rule.name, - actionParams: transformSummaryActionParams({ - alerts: summarizedAlerts, - rule: this.rule, - ruleTypeId: this.ruleType.id, - actionId: action.id, - actionParams: action.params, - spaceId, - actionsPlugin, - actionTypeId, - kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl, - ruleUrl: ruleUrl?.absoluteUrl, - }), - }), - }; - - await this.actionRunOrAddToBulk({ - enqueueOptions: this.getEnqueueOptions(actionToRun), - bulkActions, - }); + const connectorAdapter = this.taskRunnerContext.connectorAdapterRegistry.get( + action.actionTypeId + ); + logActions[action.id] = await this.runSystemAction({ + action, + connectorAdapter, + summarizedAlerts, + rule: this.rule, + spaceId, + bulkActions, + }); + } else if (!this.isSystemAction(action) && alert) { + const defaultAction = action as RuleAction; + logActions[defaultAction.id] = await this.runAction({ + action, + spaceId, + alert, + ruleId, + bulkActions, + }); + const actionGroup = defaultAction.group; + if (!this.isRecoveredAlert(actionGroup)) { if (isActionOnInterval(action)) { - throttledSummaryActions[action.uuid!] = { date: new Date().toISOString() }; - } - - logActions[action.id] = { - id: action.id, - typeId: action.actionTypeId, - alertSummary: { - new: summarizedAlerts.new.count, - ongoing: summarizedAlerts.ongoing.count, - recovered: summarizedAlerts.recovered.count, - }, - }; - } else { - const ruleUrl = this.buildRuleUrl(spaceId); - const executableAlert = alert!; - const transformActionParamsOptions: TransformActionParamsOptions = { - actionsPlugin, - alertId: ruleId, - alertType: this.ruleType.id, - actionTypeId, - alertName: this.rule.name, - spaceId, - tags: this.rule.tags, - alertInstanceId: executableAlert.getId(), - alertUuid: executableAlert.getUuid(), - alertActionGroup: actionGroup, - alertActionGroupName: this.ruleTypeActionGroups!.get(actionGroup)!, - context: executableAlert.getContext(), - actionId: action.id, - state: executableAlert.getState(), - kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl, - alertParams: this.rule.params, - actionParams: action.params, - flapping: executableAlert.getFlapping(), - ruleUrl: ruleUrl?.absoluteUrl, - consecutiveMatches: executableAlert.getActiveCount(), - }; - - if (executableAlert.isAlertAsData()) { - transformActionParamsOptions.aadAlert = executableAlert.getAlertAsData(); - } - - const actionToRun = { - ...action, - params: injectActionParams({ - actionTypeId, - ruleUrl, - ruleName: this.rule.name, - actionParams: transformActionParams(transformActionParamsOptions), - }), - }; - - await this.actionRunOrAddToBulk({ - enqueueOptions: this.getEnqueueOptions(actionToRun), - bulkActions, - }); - - logActions[action.id] = { - id: action.id, - typeId: action.actionTypeId, - alertId: alert.getId(), - alertGroup: action.group, - }; - - if (!this.isRecoveredAlert(actionGroup)) { - if (isActionOnInterval(action)) { - alert.updateLastScheduledActions( - action.group as ActionGroupIds, - generateActionHash(action), - action.uuid - ); - } else { - alert.updateLastScheduledActions(action.group as ActionGroupIds); - } - alert.unscheduleActions(); + alert.updateLastScheduledActions( + defaultAction.group as ActionGroupIds, + generateActionHash(action), + defaultAction.uuid + ); + } else { + alert.updateLastScheduledActions(defaultAction.group as ActionGroupIds); } + alert.unscheduleActions(); } } + } - if (!!bulkActions.length) { - for (const c of chunk(bulkActions, CHUNK_SIZE)) { - let enqueueResponse; - try { - enqueueResponse = await this.actionsClient!.bulkEnqueueExecution(c); - } catch (e) { - if (e.statusCode === 404) { - throw createTaskRunError(e, TaskErrorSource.USER); - } - throw createTaskRunError(e, TaskErrorSource.FRAMEWORK); - } - if (enqueueResponse.errors) { - bulkActionsResponse = bulkActionsResponse.concat( - enqueueResponse.items.filter( - (i) => i.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR - ) - ); + if (!!bulkActions.length) { + for (const c of chunk(bulkActions, CHUNK_SIZE)) { + let enqueueResponse; + try { + enqueueResponse = await this.actionsClient!.bulkEnqueueExecution(c); + } catch (e) { + if (e.statusCode === 404) { + throw createTaskRunError(e, TaskErrorSource.USER); } + throw createTaskRunError(e, TaskErrorSource.FRAMEWORK); + } + if (enqueueResponse.errors) { + bulkActionsResponse = bulkActionsResponse.concat( + enqueueResponse.items.filter( + (i) => i.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR + ) + ); } } + } - if (!!bulkActionsResponse.length) { - for (const r of bulkActionsResponse) { - if (r.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR) { - ruleRunMetricsStore.setHasReachedQueuedActionsLimit(true); - ruleRunMetricsStore.decrementNumberOfTriggeredActions(); - ruleRunMetricsStore.decrementNumberOfTriggeredActionsByConnectorType(r.actionTypeId); - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId: r.actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - - logger.debug( - `Rule "${this.rule.id}" skipped scheduling action "${r.id}" because the maximum number of queued actions has been reached.` - ); + if (!!bulkActionsResponse.length) { + for (const r of bulkActionsResponse) { + if (r.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR) { + ruleRunMetricsStore.setHasReachedQueuedActionsLimit(true); + ruleRunMetricsStore.decrementNumberOfTriggeredActions(); + ruleRunMetricsStore.decrementNumberOfTriggeredActionsByConnectorType(r.actionTypeId); + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId: r.actionTypeId, + status: ActionsCompletion.PARTIAL, + }); - delete logActions[r.id]; - } + logger.debug( + `Rule "${this.rule.id}" skipped scheduling action "${r.id}" because the maximum number of queued actions has been reached.` + ); + + delete logActions[r.id]; } } + } - const logActionsValues = Object.values(logActions); - if (!!logActionsValues.length) { - for (const action of logActionsValues) { - alertingEventLogger.logAction(action); - } + const logActionsValues = Object.values(logActions); + if (!!logActionsValues.length) { + for (const action of logActionsValues) { + alertingEventLogger.logAction(action); } } + return { throttledSummaryActions }; } + private async runSummarizedAction({ + action, + summarizedAlerts, + spaceId, + bulkActions, + }: RunSummarizedActionArgs): Promise { + const { start, end } = getSummaryActionTimeBounds( + action, + this.rule.schedule, + this.previousStartedAt + ); + const ruleUrl = this.buildRuleUrl(spaceId, start, end); + const actionToRun = { + ...action, + params: injectActionParams({ + actionTypeId: action.actionTypeId, + ruleUrl, + ruleName: this.rule.name, + actionParams: transformSummaryActionParams({ + alerts: summarizedAlerts, + rule: this.rule, + ruleTypeId: this.ruleType.id, + actionId: action.id, + actionParams: action.params, + spaceId, + actionsPlugin: this.taskRunnerContext.actionsPlugin, + actionTypeId: action.actionTypeId, + kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl, + ruleUrl: ruleUrl?.absoluteUrl, + }), + }), + }; + + await this.actionRunOrAddToBulk({ + enqueueOptions: this.getEnqueueOptions(actionToRun), + bulkActions, + }); + + return { + id: action.id, + typeId: action.actionTypeId, + alertSummary: { + new: summarizedAlerts.new.count, + ongoing: summarizedAlerts.ongoing.count, + recovered: summarizedAlerts.recovered.count, + }, + }; + } + + private async runSystemAction({ + action, + spaceId, + connectorAdapter, + summarizedAlerts, + rule, + bulkActions, + }: RunSystemActionArgs): Promise { + const ruleUrl = this.buildRuleUrl(spaceId); + + const connectorAdapterActionParams = connectorAdapter.buildActionParams({ + alerts: summarizedAlerts, + rule: { id: rule.id, tags: rule.tags, name: rule.name }, + ruleUrl: ruleUrl?.absoluteUrl, + spaceId, + params: action.params, + }); + + const actionToRun = Object.assign(action, { params: connectorAdapterActionParams }); + + await this.actionRunOrAddToBulk({ + enqueueOptions: this.getEnqueueOptions(actionToRun), + bulkActions, + }); + + return { + id: action.id, + typeId: action.actionTypeId, + alertSummary: { + new: summarizedAlerts.new.count, + ongoing: summarizedAlerts.ongoing.count, + recovered: summarizedAlerts.recovered.count, + }, + }; + } + + private async runAction({ + action, + spaceId, + alert, + ruleId, + bulkActions, + }: RunActionArgs): Promise { + const ruleUrl = this.buildRuleUrl(spaceId); + const executableAlert = alert!; + const actionGroup = action.group as ActionGroupIds; + const transformActionParamsOptions: TransformActionParamsOptions = { + actionsPlugin: this.taskRunnerContext.actionsPlugin, + alertId: ruleId, + alertType: this.ruleType.id, + actionTypeId: action.actionTypeId, + alertName: this.rule.name, + spaceId, + tags: this.rule.tags, + alertInstanceId: executableAlert.getId(), + alertUuid: executableAlert.getUuid(), + alertActionGroup: actionGroup, + alertActionGroupName: this.ruleTypeActionGroups!.get(actionGroup)!, + context: executableAlert.getContext(), + actionId: action.id, + state: executableAlert.getState(), + kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl, + alertParams: this.rule.params, + actionParams: action.params, + flapping: executableAlert.getFlapping(), + ruleUrl: ruleUrl?.absoluteUrl, + }; + + if (executableAlert.isAlertAsData()) { + transformActionParamsOptions.aadAlert = executableAlert.getAlertAsData(); + } + const actionToRun = { + ...action, + params: injectActionParams({ + actionTypeId: action.actionTypeId, + ruleUrl, + ruleName: this.rule.name, + actionParams: transformActionParams(transformActionParamsOptions), + }), + }; + + await this.actionRunOrAddToBulk({ + enqueueOptions: this.getEnqueueOptions(actionToRun), + bulkActions, + }); + + return { + id: action.id, + typeId: action.actionTypeId, + alertId: alert.getId(), + alertGroup: action.group, + }; + } + private logNumberOfFilteredAlerts({ numberOfAlerts = 0, numberOfSummarizedAlerts = 0, @@ -420,7 +548,7 @@ export class ExecutionHandler< }: { numberOfAlerts: number; numberOfSummarizedAlerts: number; - action: RuleAction; + action: RuleAction | RuleSystemAction; }) { const count = numberOfAlerts - numberOfSummarizedAlerts; if (count > 0) { @@ -449,12 +577,16 @@ export class ExecutionHandler< return false; } - private isExecutableAction(action: RuleAction) { + private isExecutableAction(action: RuleAction | RuleSystemAction) { return this.taskRunnerContext.actionsPlugin.isActionExecutable(action.id, action.actionTypeId, { notifyUsage: true, }); } + private isSystemAction(action?: RuleAction | RuleSystemAction): action is RuleSystemAction { + return this.taskRunnerContext.actionsPlugin.isSystemActionConnector(action?.id ?? ''); + } + private isRecoveredAlert(actionGroup: string) { return actionGroup === this.ruleType.recoveryActionGroup.id; } @@ -548,7 +680,7 @@ export class ExecutionHandler< } } - private getEnqueueOptions(action: RuleAction): EnqueueExecutionOptions { + private getEnqueueOptions(action: RuleAction | RuleSystemAction): EnqueueExecutionOptions { const { apiKey, ruleConsumer, @@ -589,14 +721,15 @@ export class ExecutionHandler< const executables = []; for (const action of this.rule.actions) { const alertsArray = Object.entries(alerts); - let summarizedAlerts = null; + if (this.shouldGetSummarizedAlerts({ action, throttledSummaryActions })) { summarizedAlerts = await this.getSummarizedAlerts({ action, spaceId: this.taskInstance.params.spaceId, ruleId: this.taskInstance.params.alertId, }); + if (!isSummaryActionOnInterval(action)) { this.logNumberOfFilteredAlerts({ numberOfAlerts: alertsArray.length, @@ -641,7 +774,7 @@ export class ExecutionHandler< // notifications for flapping pending recovered alerts if ( alert.getPendingRecoveredCount() > 0 && - action.frequency?.notifyWhen !== RuleNotifyWhen.CHANGE + action?.frequency?.notifyWhen !== RuleNotifyWhen.CHANGE ) { continue; } @@ -666,6 +799,22 @@ export class ExecutionHandler< } } + if (!this.canGetSummarizedAlerts()) { + return executables; + } + + for (const systemAction of this.rule?.systemActions ?? []) { + const summarizedAlerts = await this.getSummarizedAlerts({ + action: systemAction, + spaceId: this.taskInstance.params.spaceId, + ruleId: this.taskInstance.params.alertId, + }); + + if (summarizedAlerts && summarizedAlerts.all.count !== 0) { + executables.push({ action: systemAction, summarizedAlerts }); + } + } + return executables; } @@ -688,6 +837,7 @@ export class ExecutionHandler< } return false; } + if (action.useAlertDataForTemplate) { return true; } @@ -714,7 +864,7 @@ export class ExecutionHandler< ruleId, spaceId, }: { - action: RuleAction; + action: RuleAction | RuleSystemAction; ruleId: string; spaceId: string; }): Promise { @@ -722,13 +872,13 @@ export class ExecutionHandler< ruleId, spaceId, excludedAlertInstanceIds: this.rule.mutedInstanceIds, - alertsFilter: action.alertsFilter, + alertsFilter: this.isSystemAction(action) ? undefined : (action as RuleAction).alertsFilter, }; let options: GetSummarizedAlertsParams; - if (isActionOnInterval(action)) { - const throttleMills = parseDuration(action.frequency!.throttle!); + if (!this.isSystemAction(action) && isActionOnInterval(action)) { + const throttleMills = parseDuration((action as RuleAction).frequency!.throttle!); const start = new Date(Date.now() - throttleMills); options = { diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index b2a984ea5768f..ae8eccfcb1f86 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -15,6 +15,7 @@ import { RuleLastRunOutcomeOrderMap, RuleLastRunOutcomes, SanitizedRule, + SanitizedRuleAction, } from '../../common'; import { getDefaultMonitoring } from '../lib/monitoring'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; @@ -284,7 +285,7 @@ export const mockedRule: SanitizedRule return { ...action, id: action.uuid, - }; + } as SanitizedRuleAction; }), isSnoozedUntil: undefined, }; diff --git a/x-pack/plugins/alerting/server/task_runner/get_maintenance_windows.test.ts b/x-pack/plugins/alerting/server/task_runner/get_maintenance_windows.test.ts index 3cc5e54283dc8..f8a0508346a24 100644 --- a/x-pack/plugins/alerting/server/task_runner/get_maintenance_windows.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/get_maintenance_windows.test.ts @@ -20,6 +20,7 @@ import { } from './get_maintenance_windows'; import { getFakeKibanaRequest } from './rule_loader'; import { TaskRunnerContext } from './types'; +import { FilterStateStore } from '@kbn/es-query'; const logger = loggingSystemMock.create().get(); const mockBasePathService = { set: jest.fn() }; @@ -206,7 +207,7 @@ describe('filterMaintenanceWindows', () => { type: 'phrase', }, $state: { - store: 'appState', + store: FilterStateStore.APP_STATE, }, query: { match_phrase: { @@ -274,7 +275,7 @@ describe('filterMaintenanceWindowsIds', () => { type: 'phrase', }, $state: { - store: 'appState', + store: FilterStateStore.APP_STATE, }, query: { match_phrase: { diff --git a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts b/x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts index c2ac1ab38a2fa..2edd66bc6f43c 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts @@ -60,6 +60,7 @@ describe('rule_action_helper', () => { const result = isSummaryAction(mockAction); expect(result).toBe(false); }); + test('should return false if the action does not have frequency field', () => { const result = isSummaryAction(mockOldAction); expect(result).toBe(false); diff --git a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts b/x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts index a23323ffee2b7..8845988e06bd4 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts @@ -15,16 +15,16 @@ import { } from '../../common'; export const isSummaryAction = (action?: RuleAction) => { - return action?.frequency?.summary || false; + return action?.frequency?.summary ?? false; }; export const isActionOnInterval = (action?: RuleAction) => { - if (!action?.frequency) { + if (action?.frequency == null) { return false; } return ( - action.frequency.notifyWhen === RuleNotifyWhenTypeValues[2] && - typeof action.frequency.throttle === 'string' + action?.frequency.notifyWhen === RuleNotifyWhenTypeValues[2] && + typeof action?.frequency.throttle === 'string' ); }; @@ -44,14 +44,19 @@ export const isSummaryActionThrottled = ({ if (!isActionOnInterval(action)) { return false; } + if (!throttledSummaryActions) { return false; } + const throttledAction = throttledSummaryActions[action?.uuid!]; + if (!throttledAction) { return false; } + let throttleMills = 0; + try { throttleMills = parseDuration(action?.frequency!.throttle!); } catch (e) { @@ -86,6 +91,7 @@ export const getSummaryActionsFromTaskState = ({ (action) => action.frequency?.summary && (action.uuid === key || generateActionHash(action) === key) ); + if (actionExists) { // replace hash with uuid newObj[actionExists.uuid!] = val; @@ -102,6 +108,7 @@ export const getSummaryActionTimeBounds = ( if (!isSummaryAction(action)) { return { start: undefined, end: undefined }; } + let startDate: Date; const now = Date.now(); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index a75a73a124953..172c490cdbc8c 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -83,6 +83,7 @@ import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; import { rulesSettingsClientMock } from '../rules_settings_client.mock'; import { maintenanceWindowClientMock } from '../maintenance_window_client.mock'; import { alertsServiceMock } from '../alerts_service/alerts_service.mock'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; import { getMockMaintenanceWindow } from '../data/maintenance_window/test_helpers'; import { alertsClientMock } from '../alerts_client/alerts_client.mock'; import { MaintenanceWindow } from '../application/maintenance_window/types'; @@ -148,6 +149,7 @@ describe('Task Runner', () => { } as DataViewsServerPluginStart; const alertsService = alertsServiceMock.create(); const maintenanceWindowClient = maintenanceWindowClientMock.create(); + const connectorAdapterRegistry = new ConnectorAdapterRegistry(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; @@ -186,6 +188,7 @@ describe('Task Runner', () => { }, getRulesSettingsClientWithRequest: jest.fn().mockReturnValue(rulesSettingsClientMock.create()), getMaintenanceWindowClientWithRequest: jest.fn().mockReturnValue(maintenanceWindowClient), + connectorAdapterRegistry, }; const ephemeralTestParams: Array< diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts index ed58d75dc1038..f28c849b2bc37 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts @@ -98,6 +98,7 @@ import { VERSION, ALERT_CONSECUTIVE_MATCHES, } from '@kbn/rule-data-utils'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -171,6 +172,7 @@ describe('Task Runner', () => { const mockLegacyAlertsClient = legacyAlertsClientMock.create(); const ruleRunMetricsStore = ruleRunMetricsStoreMock.create(); const maintenanceWindowClient = maintenanceWindowClientMock.create(); + const connectorAdapterRegistry = new ConnectorAdapterRegistry(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; @@ -211,6 +213,7 @@ describe('Task Runner', () => { .fn() .mockReturnValue(rulesSettingsClientMock.create()), getMaintenanceWindowClientWithRequest: jest.fn().mockReturnValue(maintenanceWindowClient), + connectorAdapterRegistry, }; describe(`using ${label} for alert indices`, () => { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index 8723fa98ef4fc..08d3cbb81244b 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -57,6 +57,7 @@ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { rulesSettingsClientMock } from '../rules_settings_client.mock'; import { maintenanceWindowClientMock } from '../maintenance_window_client.mock'; import { alertsServiceMock } from '../alerts_service/alerts_service.mock'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; import { TaskRunnerContext } from './types'; @@ -111,6 +112,7 @@ describe('Task Runner Cancel', () => { const uiSettingsService = uiSettingsServiceMock.createStartContract(); const dataPlugin = dataPluginMock.createStartContract(); const inMemoryMetrics = inMemoryMetricsMock.create(); + const connectorAdapterRegistry = new ConnectorAdapterRegistry(); type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { actionsPlugin: jest.Mocked; @@ -151,6 +153,7 @@ describe('Task Runner Cancel', () => { getMaintenanceWindowClientWithRequest: jest .fn() .mockReturnValue(maintenanceWindowClientMock.create()), + connectorAdapterRegistry, }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 8b3d3c7b6e27d..43a870d57c08a 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -33,6 +33,7 @@ import { rulesSettingsClientMock } from '../rules_settings_client.mock'; import { maintenanceWindowClientMock } from '../maintenance_window_client.mock'; import { alertsServiceMock } from '../alerts_service/alerts_service.mock'; import { schema } from '@kbn/config-schema'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; import { TaskRunnerContext } from './types'; const inMemoryMetrics = inMemoryMetricsMock.create(); @@ -98,6 +99,7 @@ describe('Task Runner Factory', () => { const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); const rulesClient = rulesClientMock.create(); + const connectorAdapterRegistry = new ConnectorAdapterRegistry(); const taskRunnerFactoryInitializerParams: jest.Mocked = { data: dataPlugin, @@ -132,6 +134,7 @@ describe('Task Runner Factory', () => { getMaintenanceWindowClientWithRequest: jest .fn() .mockReturnValue(maintenanceWindowClientMock.create()), + connectorAdapterRegistry, }; beforeEach(() => { diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index 5c80f7b083636..dd7b14fc7f4aa 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -39,6 +39,7 @@ import { RuleTypeState, RuleAction, RuleAlertData, + RuleSystemAction, RulesSettingsFlappingProperties, RulesSettingsQueryDelayProperties, } from '../../common'; @@ -55,6 +56,7 @@ import { } from '../types'; import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; export interface RuleTaskRunResult { state: RuleTaskState; @@ -124,7 +126,7 @@ export type Executable< ActionGroupIds extends string, RecoveryActionGroupId extends string > = { - action: RuleAction; + action: RuleAction | RuleSystemAction; } & ( | { alert: Alert; @@ -174,4 +176,5 @@ export interface TaskRunnerContext { supportsEphemeralTasks: boolean; uiSettings: UiSettingsServiceStart; usageCounter?: UsageCounter; + connectorAdapterRegistry: ConnectorAdapterRegistry; } diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index eeb13576ce39d..5316bcaa1a5ae 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -27,6 +27,7 @@ import { SharePluginStart } from '@kbn/share-plugin/server'; import type { DefaultAlert, FieldMap } from '@kbn/alerts-as-data-utils'; import { Alert } from '@kbn/alerts-as-data-utils'; import { Filter } from '@kbn/es-query'; +import { ActionsApiRequestHandlerContext } from '@kbn/actions-plugin/server'; import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { RulesClient } from './rules_client'; @@ -87,6 +88,7 @@ export interface AlertingApiRequestHandlerContext { */ export type AlertingRequestHandlerContext = CustomRequestHandlerContext<{ alerting: AlertingApiRequestHandlerContext; + actions: ActionsApiRequestHandlerContext; }>; /** @@ -435,7 +437,7 @@ export interface RawRuleAlertsFilter extends AlertsFilter { export interface RawRuleAction extends SavedObjectAttributes { uuid: string; - group: string; + group?: string; actionRef: string; actionTypeId: string; params: RuleActionParams; @@ -445,6 +447,7 @@ export interface RawRuleAction extends SavedObjectAttributes { throttle: string | null; }; alertsFilter?: RawRuleAlertsFilter; + useAlertDataAsTemplate?: boolean; } // note that the `error` property is "null-able", as we're doing a partial diff --git a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx index 7ac7d435f89d9..2cca4638876c9 100644 --- a/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alert_form.test.tsx @@ -29,13 +29,6 @@ import { I18nProvider } from '@kbn/i18n-react'; import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -interface AlertAction { - group: string; - id: string; - actionTypeId: string; - params: unknown; -} - jest.mock('@kbn/triggers-actions-ui-plugin/public/application/lib/action_connector_api', () => ({ loadAllActions: jest.fn(), loadActionTypes: jest.fn(), @@ -264,7 +257,7 @@ describe('alert_form', () => { setActionIdByIndex={(id: string, index: number) => { initialAlert.actions[index].id = id; }} - setActions={(_updatedActions: AlertAction[]) => {}} + setActions={() => {}} setActionParamsProperty={(key: string, value: unknown, index: number) => (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) } diff --git a/x-pack/plugins/monitoring/server/rules/base_rule.ts b/x-pack/plugins/monitoring/server/rules/base_rule.ts index b457185704b76..da9f8edb94b2e 100644 --- a/x-pack/plugins/monitoring/server/rules/base_rule.ts +++ b/x-pack/plugins/monitoring/server/rules/base_rule.ts @@ -170,19 +170,21 @@ export class BaseRule { if (!action) { continue; } - ruleActions.push({ - group: 'default', - id: actionData.id, - params: { - message: '{{context.internalShortMessage}}', - ...actionData.config, - }, - frequency: { - summary: false, - notifyWhen: RuleNotifyWhen.THROTTLE, - throttle, - }, - }); + if (!action.isSystemAction) { + ruleActions.push({ + group: 'default', + id: actionData.id, + params: { + message: '{{context.internalShortMessage}}', + ...actionData.config, + }, + frequency: { + summary: false, + notifyWhen: RuleNotifyWhen.THROTTLE, + throttle, + }, + }); + } } return await rulesClient.create({ diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts index 95b899d1fe03f..1526a78cbf75f 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/default_alerts/default_alert_service.ts @@ -82,12 +82,11 @@ export class DefaultAlertService { }, }); - const alert = data?.[0]; - if (!alert) { + if (data.length === 0) { return; } - - return { ...alert, ruleTypeId: alert.alertTypeId }; + const { actions = [], systemActions = [], ...alert } = data[0]; + return { ...alert, actions: [...actions, ...systemActions], ruleTypeId: alert.alertTypeId }; } async createDefaultAlertIfNotExist(ruleType: DefaultRuleType, name: string, interval: string) { const alert = await this.getExistingAlert(ruleType); @@ -96,9 +95,12 @@ export class DefaultAlertService { } const actions = await this.getAlertActions(ruleType); - const rulesClient = (await this.context.alerting)?.getRulesClient(); - const newAlert = await rulesClient.create<{}>({ + const { + actions: actionsFromRules = [], + systemActions = [], + ...newAlert + } = await rulesClient.create<{}>({ data: { actions, params: {}, @@ -111,7 +113,12 @@ export class DefaultAlertService { throttle: null, }, }); - return { ...newAlert, ruleTypeId: newAlert.alertTypeId }; + + return { + ...newAlert, + actions: [...actionsFromRules, ...systemActions], + ruleTypeId: newAlert.alertTypeId, + }; } updateStatusRule() { @@ -127,7 +134,11 @@ export class DefaultAlertService { const alert = await this.getExistingAlert(ruleType); if (alert) { const actions = await this.getAlertActions(ruleType); - const updatedAlert = await rulesClient.update({ + const { + actions: actionsFromRules = [], + systemActions = [], + ...updatedAlert + } = await rulesClient.update({ id: alert.id, data: { actions, @@ -137,7 +148,11 @@ export class DefaultAlertService { params: alert.params, }, }); - return { ...updatedAlert, ruleTypeId: updatedAlert.alertTypeId }; + return { + ...updatedAlert, + actions: [...actionsFromRules, ...systemActions], + ruleTypeId: updatedAlert.alertTypeId, + }; } return await this.createDefaultAlertIfNotExist(ruleType, name, interval); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_fetch_rule_action_connectors.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_fetch_rule_action_connectors.ts index 039fd963137f7..7b1e140c6c5a9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_fetch_rule_action_connectors.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_fetch_rule_action_connectors.ts @@ -49,6 +49,7 @@ export function useFetchRuleActionConnectors({ ruleActions }: FetchRuleActionCon } const allActions = await loadAllActions({ http, + includeSystemActions: true, }); setActionConnector((oldState: FetchActionConnectors) => ({ ...oldState, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.test.ts index 182abc1507a34..e5575ba28fad5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.test.ts @@ -47,6 +47,7 @@ describe('loadActionTypes', () => { expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` Array [ "/api/actions/connector_types", + Object {}, ] `); }); @@ -92,4 +93,131 @@ describe('loadActionTypes', () => { ] `); }); + + test('should call the internal list types API if includeSystemActions=true', async () => { + const apiResponseValue = [ + { + id: '.test-system-action', + name: 'System action name', + enabled: true, + enabled_in_config: true, + enabled_in_license: true, + supported_feature_ids: ['alerting'], + minimum_license_required: 'basic', + is_system_action_type: true, + }, + { + id: 'test', + name: 'Test', + enabled: true, + enabled_in_config: true, + enabled_in_license: true, + supported_feature_ids: ['alerting'], + minimum_license_required: 'basic', + is_system_action_type: false, + }, + ]; + + http.get.mockResolvedValueOnce(apiResponseValue); + + const resolvedValue: ActionType[] = [ + { + id: '.test-system-action', + name: 'System action name', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + supportedFeatureIds: ['alerting'], + minimumLicenseRequired: 'basic', + isSystemActionType: true, + }, + { + id: 'test', + name: 'Test', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + supportedFeatureIds: ['alerting'], + minimumLicenseRequired: 'basic', + isSystemActionType: false, + }, + ]; + + const result = await loadActionTypes({ http, includeSystemActions: true }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/actions/connector_types", + Object {}, + ] + `); + }); + + test('should call the internal list types API with query parameter if specified and includeSystemActions=true', async () => { + const apiResponseValue = [ + { + id: '.test-system-action', + name: 'System action name', + enabled: true, + enabled_in_config: true, + enabled_in_license: true, + supported_feature_ids: ['alerting'], + minimum_license_required: 'basic', + is_system_action_type: true, + }, + { + id: 'test', + name: 'Test', + enabled: true, + enabled_in_config: true, + enabled_in_license: true, + supported_feature_ids: ['alerting'], + minimum_license_required: 'basic', + is_system_action_type: false, + }, + ]; + + http.get.mockResolvedValueOnce(apiResponseValue); + + const resolvedValue: ActionType[] = [ + { + id: '.test-system-action', + name: 'System action name', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + supportedFeatureIds: ['alerting'], + minimumLicenseRequired: 'basic', + isSystemActionType: true, + }, + { + id: 'test', + name: 'Test', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + supportedFeatureIds: ['alerting'], + minimumLicenseRequired: 'basic', + isSystemActionType: false, + }, + ]; + + const result = await loadActionTypes({ + http, + featureId: 'alerting', + includeSystemActions: true, + }); + + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/actions/connector_types", + Object { + "query": Object { + "feature_id": "alerting", + }, + }, + ] + `); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.ts index be16cfc65309e..8ec463113b6a0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connector_types.ts @@ -6,7 +6,11 @@ */ import { HttpSetup } from '@kbn/core/public'; -import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common'; +import { + AsApiContract, + INTERNAL_BASE_ACTION_API_PATH, + RewriteRequestCase, +} from '@kbn/actions-plugin/common'; import { BASE_ACTION_API_PATH } from '../../constants'; import type { ActionType } from '../../../types'; @@ -33,21 +37,22 @@ const rewriteBodyReq: RewriteRequestCase = ({ export async function loadActionTypes({ http, featureId, + includeSystemActions = false, }: { http: HttpSetup; featureId?: string; + includeSystemActions?: boolean; }): Promise { + const path = includeSystemActions + ? `${INTERNAL_BASE_ACTION_API_PATH}/connector_types` + : `${BASE_ACTION_API_PATH}/connector_types`; + const res = featureId - ? await http.get[0]>( - `${BASE_ACTION_API_PATH}/connector_types`, - { - query: { - feature_id: featureId, - }, - } - ) - : await http.get[0]>( - `${BASE_ACTION_API_PATH}/connector_types` - ); + ? await http.get[0]>(path, { + query: { + feature_id: featureId, + }, + }) + : await http.get[0]>(path, {}); return rewriteResponseRes(res); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.test.ts index a9216eaec2be5..3633e4234e32c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.test.ts @@ -6,6 +6,7 @@ */ import { httpServiceMock } from '@kbn/core/public/mocks'; +import { ActionConnectorProps } from '../../../types'; import { loadAllActions } from '.'; const http = httpServiceMock.createStartContract(); @@ -14,14 +15,88 @@ beforeEach(() => jest.resetAllMocks()); describe('loadAllActions', () => { test('should call getAll actions API', async () => { - http.get.mockResolvedValueOnce([]); + const apiResponseValue = [ + { + id: 'test-connector', + name: 'Test', + connector_type_id: 'test', + is_preconfigured: false, + is_deprecated: false, + is_missing_secrets: false, + is_system_action: false, + referenced_by_count: 0, + secrets: {}, + config: {}, + }, + ]; + + const resolvedValue: Array> = [ + { + id: 'test-connector', + name: 'Test', + actionTypeId: 'test', + isPreconfigured: false, + isDeprecated: false, + isMissingSecrets: false, + isSystemAction: false, + referencedByCount: 0, + secrets: {}, + config: {}, + }, + ]; + + http.get.mockResolvedValueOnce(apiResponseValue); const result = await loadAllActions({ http }); - expect(result).toEqual([]); + + expect(result).toEqual(resolvedValue); expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` Array [ "/api/actions/connectors", ] `); }); + + test('should call the internal getAll actions API if includeSystemActions=true', async () => { + const apiResponseValue = [ + { + id: '.test-system-action', + name: 'System action name', + connector_type_id: 'test', + is_preconfigured: false, + is_deprecated: false, + is_missing_secrets: false, + is_system_action: true, + referenced_by_count: 0, + secrets: {}, + config: {}, + }, + ]; + + const resolvedValue: Array> = [ + { + id: '.test-system-action', + name: 'System action name', + actionTypeId: 'test', + isPreconfigured: false, + isDeprecated: false, + isMissingSecrets: false, + isSystemAction: true, + referencedByCount: 0, + secrets: {}, + config: {}, + }, + ]; + + http.get.mockResolvedValueOnce(apiResponseValue); + + const result = await loadAllActions({ http, includeSystemActions: true }); + + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/actions/connectors", + ] + `); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.ts index 6938654b294ac..3e5fea634b479 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_connector_api/connectors.ts @@ -5,8 +5,12 @@ * 2.0. */ import { HttpSetup } from '@kbn/core/public'; -import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common'; -import { BASE_ACTION_API_PATH } from '../../constants'; +import { + AsApiContract, + BASE_ACTION_API_PATH, + INTERNAL_BASE_ACTION_API_PATH, + RewriteRequestCase, +} from '@kbn/actions-plugin/common'; import type { ActionConnector, ActionConnectorProps } from '../../../types'; const rewriteResponseRes = ( @@ -37,10 +41,21 @@ const transformConnector: RewriteRequestCase< ...res, }); -export async function loadAllActions({ http }: { http: HttpSetup }): Promise { - const res = await http.get[0]>( - `${BASE_ACTION_API_PATH}/connectors` - ); +export async function loadAllActions({ + http, + includeSystemActions = false, +}: { + http: HttpSetup; + includeSystemActions?: boolean; +}): Promise { + // Use the internal get_all_system route to load all action connectors and preconfigured system action connectors + // This is necessary to load UI elements that require system action connectors, even if they're not selectable and + // editable from the connector selection UI like a normal action connector. + const path = includeSystemActions + ? `${INTERNAL_BASE_ACTION_API_PATH}/connectors` + : `${BASE_ACTION_API_PATH}/connectors`; + + const res = await http.get[0]>(path); return rewriteResponseRes(res) as ActionConnector[]; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts index b00b874b079ef..e0ba1b232f283 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/common_transformations.ts @@ -6,35 +6,34 @@ */ import { RuleExecutionStatus } from '@kbn/alerting-plugin/common'; import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common'; -import type { Rule, RuleAction, ResolvedRule, RuleLastRun } from '../../../types'; +import type { Rule, RuleUiAction, ResolvedRule, RuleLastRun } from '../../../types'; -const transformAction: RewriteRequestCase = ({ - uuid, - group, - id, - connector_type_id: actionTypeId, - params, - frequency, - alerts_filter: alertsFilter, - use_alert_data_for_template: useAlertDataForTemplate, -}) => ({ - group, - id, - params, - actionTypeId, - ...(typeof useAlertDataForTemplate !== 'undefined' ? { useAlertDataForTemplate } : {}), - ...(frequency - ? { - frequency: { - summary: frequency.summary, - notifyWhen: frequency.notify_when, - throttle: frequency.throttle, - }, - } - : {}), - ...(alertsFilter ? { alertsFilter } : {}), - ...(uuid && { uuid }), -}); +const transformAction: RewriteRequestCase = (action) => { + const { uuid, id, connector_type_id: actionTypeId, params } = action; + return { + ...('group' in action && action.group ? { group: action.group } : {}), + id, + params, + actionTypeId, + ...('use_alert_data_for_template' in action && + typeof action.use_alert_data_for_template !== 'undefined' + ? { useAlertDataForTemplate: action.use_alert_data_for_template } + : {}), + ...('frequency' in action && action.frequency + ? { + frequency: { + summary: action.frequency.summary, + notifyWhen: action.frequency.notify_when, + throttle: action.frequency.throttle, + }, + } + : {}), + ...('alerts_filter' in action && action.alerts_filter + ? { alertsFilter: action.alerts_filter } + : {}), + ...(uuid && { uuid }), + }; +}; const transformExecutionStatus: RewriteRequestCase = ({ last_execution_date: lastExecutionDate, @@ -92,7 +91,7 @@ export const transformRule: RewriteRequestCase = ({ snoozeSchedule, executionStatus: executionStatus ? transformExecutionStatus(executionStatus) : undefined, actions: actions - ? actions.map((action: AsApiContract) => transformAction(action)) + ? actions.map((action: AsApiContract) => transformAction(action)) : [], scheduledTaskId, isSnoozedUntil, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.test.ts index b27d9cad0c056..cf32f04a2bc23 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.test.ts @@ -47,6 +47,11 @@ describe('createRule', () => { summary: false, }, }, + { + id: '.test-system-action', + params: {}, + connector_type_id: '.system-action', + }, ], scheduled_task_id: '1', execution_status: { status: 'pending', last_execution_date: '2021-04-01T21:33:13.250Z' }, @@ -56,6 +61,7 @@ describe('createRule', () => { active: 10, }, }; + const ruleToCreate: Omit< RuleUpdates, 'createdBy' | 'updatedBy' | 'muteAll' | 'mutedInstanceIds' | 'executionStatus' @@ -94,6 +100,11 @@ describe('createRule', () => { summary: false, }, }, + { + id: '.test-system-action', + params: {}, + actionTypeId: '.system-action', + }, ], createdAt: new Date('2021-04-01T21:33:13.247Z'), updatedAt: new Date('2021-04-01T21:33:13.247Z'), @@ -122,6 +133,11 @@ describe('createRule', () => { summary: false, }, }, + { + id: '.test-system-action', + params: {}, + actionTypeId: '.system-action', + }, ], ruleTypeId: '.index-threshold', apiKeyOwner: undefined, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.ts index 48fa1783f3c1f..c9e9dd270b049 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.ts @@ -28,22 +28,32 @@ const rewriteBodyRequest: RewriteResponseCase = ({ }): any => ({ ...res, rule_type_id: ruleTypeId, - actions: actions.map( - ({ group, id, params, frequency, alertsFilter, useAlertDataForTemplate }) => ({ - group, + actions: actions.map((action) => { + const { id, params } = action; + return { + ...('group' in action && action.group ? { group: action.group } : {}), id, params, - frequency: { - notify_when: frequency!.notifyWhen, - throttle: frequency!.throttle, - summary: frequency!.summary, - }, - alerts_filter: alertsFilter, - ...(typeof useAlertDataForTemplate !== 'undefined' - ? { use_alert_data_for_template: useAlertDataForTemplate } + ...('frequency' in action && action.frequency + ? { + frequency: { + notify_when: action.frequency!.notifyWhen, + throttle: action.frequency!.throttle, + summary: action.frequency!.summary, + }, + } : {}), - }) - ), + ...('alertsFilter' in action && action.alertsFilter + ? { + alerts_filter: action.alertsFilter, + } + : {}), + ...('useAlertDataForTemplate' in action && + typeof action.useAlertDataForTemplate !== 'undefined' + ? { use_alert_data_for_template: action.useAlertDataForTemplate } + : {}), + }; + }), ...(alertDelay ? { alert_delay: alertDelay } : {}), }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.test.ts index 591cdc83e86cf..ebe187ba88ed0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.test.ts @@ -21,7 +21,6 @@ describe('updateRule', () => { interval: '1m', }, params: {}, - actions: [], createdAt: new Date('1970-01-01T00:00:00.000Z'), updatedAt: new Date('1970-01-01T00:00:00.000Z'), apiKey: null, @@ -30,7 +29,27 @@ describe('updateRule', () => { alertDelay: { active: 10, }, + actions: [ + { + group: 'default', + id: '2', + actionTypeId: 'test', + params: {}, + useAlertDataForTemplate: false, + frequency: { + notifyWhen: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + { + id: '.test-system-action', + params: {}, + actionTypeId: '.system-action', + }, + ], }; + const resolvedValue: Rule = { ...ruleToUpdate, id: '12/3', @@ -46,15 +65,59 @@ describe('updateRule', () => { }, revision: 1, }; - http.put.mockResolvedValueOnce(resolvedValue); + + http.put.mockResolvedValueOnce({ + ...resolvedValue, + actions: [ + { + group: 'default', + id: '2', + connector_type_id: 'test', + params: {}, + use_alert_data_for_template: false, + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + { + id: '.test-system-action', + params: {}, + connector_type_id: '.system-action', + }, + ], + }); const result = await updateRule({ http, id: '12/3', rule: ruleToUpdate }); - expect(result).toEqual(resolvedValue); + + expect(result).toEqual({ + ...resolvedValue, + actions: [ + { + group: 'default', + id: '2', + actionTypeId: 'test', + params: {}, + useAlertDataForTemplate: false, + frequency: { + notifyWhen: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + { + id: '.test-system-action', + params: {}, + actionTypeId: '.system-action', + }, + ], + }); expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` Array [ "/api/alerting/rule/12%2F3", Object { - "body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[],\\"alert_delay\\":{\\"active\\":10}}", + "body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"schedule\\":{\\"interval\\":\\"1m\\"},\\"params\\":{},\\"actions\\":[{\\"group\\":\\"default\\",\\"id\\":\\"2\\",\\"params\\":{},\\"frequency\\":{\\"notify_when\\":\\"onActionGroupChange\\",\\"throttle\\":null,\\"summary\\":false},\\"use_alert_data_for_template\\":false},{\\"id\\":\\".test-system-action\\",\\"params\\":{}}],\\"alert_delay\\":{\\"active\\":10}}", }, ] `); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.ts index 80346ff2f65da..652eeb064a429 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.ts @@ -21,23 +21,31 @@ const rewriteBodyRequest: RewriteResponseCase = ({ ...res }): any => ({ ...res, - actions: actions.map( - ({ group, id, params, frequency, uuid, alertsFilter, useAlertDataForTemplate }) => ({ - group, + actions: actions.map((action) => { + const { id, params, uuid } = action; + return { + ...('group' in action ? { group: action.group } : {}), id, params, - frequency: { - notify_when: frequency!.notifyWhen, - throttle: frequency!.throttle, - summary: frequency!.summary, - }, - alerts_filter: alertsFilter, - ...(typeof useAlertDataForTemplate !== 'undefined' - ? { use_alert_data_for_template: useAlertDataForTemplate } + ...('frequency' in action + ? { + frequency: action.frequency + ? { + notify_when: action.frequency!.notifyWhen, + throttle: action.frequency!.throttle, + summary: action.frequency!.summary, + } + : undefined, + } + : {}), + ...('alertsFilter' in action ? { alerts_filter: action.alertsFilter } : {}), + ...('useAlertDataForTemplate' in action && + typeof action.useAlertDataForTemplate !== 'undefined' + ? { use_alert_data_for_template: action.useAlertDataForTemplate } : {}), ...(uuid && { uuid }), - }) - ), + }; + }), ...(alertDelay ? { alert_delay: alertDelay } : {}), }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/value_validators.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/value_validators.ts index 96c01d201b0a0..bb98c2664141d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/value_validators.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/value_validators.ts @@ -8,7 +8,7 @@ import { set } from '@kbn/safer-lodash-set'; import { constant, get } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { UserConfiguredActionConnector, IErrorObject, Rule, RuleAction } from '../../types'; +import { UserConfiguredActionConnector, IErrorObject, Rule, RuleUiAction } from '../../types'; const filterQueryRequiredError = i18n.translate( 'xpack.triggersActionsUI.sections.actionTypeForm.error.requiredFilterQuery', @@ -17,10 +17,12 @@ const filterQueryRequiredError = i18n.translate( } ); -export const validateActionFilterQuery = (actionItem: RuleAction): string | null => { - const query = actionItem.alertsFilter?.query; - if (query && !query.kql) { - return filterQueryRequiredError; +export const validateActionFilterQuery = (actionItem: RuleUiAction): string | null => { + if ('alertsFilter' in actionItem) { + const query = actionItem?.alertsFilter?.query; + if (query && !query.kql) { + return filterQueryRequiredError; + } } return null; }; @@ -70,17 +72,17 @@ export function getConnectorWithInvalidatedFields( baseConnectorErrors: IErrorObject ) { Object.keys(configErrors).forEach((errorKey) => { - if (configErrors[errorKey].length >= 1 && get(connector.config, errorKey) === undefined) { + if (configErrors[errorKey].length && get(connector.config, errorKey) === undefined) { set(connector.config, errorKey, null); } }); Object.keys(secretsErrors).forEach((errorKey) => { - if (secretsErrors[errorKey].length >= 1 && get(connector.secrets, errorKey) === undefined) { + if (secretsErrors[errorKey].length && get(connector.secrets, errorKey) === undefined) { set(connector.secrets, errorKey, null); } }); Object.keys(baseConnectorErrors).forEach((errorKey) => { - if (baseConnectorErrors[errorKey].length >= 1 && get(connector, errorKey) === undefined) { + if (baseConnectorErrors[errorKey].length && get(connector, errorKey) === undefined) { set(connector, errorKey, null); } }); @@ -94,12 +96,12 @@ export function getRuleWithInvalidatedFields( actionsErrors: IErrorObject[] ) { Object.keys(paramsErrors).forEach((errorKey) => { - if (paramsErrors[errorKey].length >= 1 && get(rule.params, errorKey) === undefined) { + if (paramsErrors[errorKey].length && get(rule.params, errorKey) === undefined) { set(rule.params, errorKey, null); } }); Object.keys(baseAlertErrors).forEach((errorKey) => { - if (baseAlertErrors[errorKey].length >= 1 && get(rule, errorKey) === undefined) { + if (baseAlertErrors[errorKey].length && get(rule, errorKey) === undefined) { set(rule, errorKey, null); } }); @@ -107,7 +109,7 @@ export function getRuleWithInvalidatedFields( const actionToValidate = rule.actions.length > index ? rule.actions[index] : null; if (actionToValidate) { Object.keys(error).forEach((errorKey) => { - if (error[errorKey].length >= 1 && get(actionToValidate!.params, errorKey) === undefined) { + if (error[errorKey].length && get(actionToValidate!.params, errorKey) === undefined) { set(actionToValidate!.params, errorKey, null); } }); 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 176936aabea5d..74b528ba7a64a 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,12 +11,13 @@ import { EuiAccordion } from '@elastic/eui'; import { coreMock } from '@kbn/core/public/mocks'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; -import { ValidationResult, Rule, RuleAction, GenericValidationResult } from '../../../types'; +import { ValidationResult, GenericValidationResult, RuleUiAction } from '../../../types'; import ActionForm from './action_form'; import { useKibana } from '../../../common/lib/kibana'; import { RecoveredActionGroup, isActionGroupDisabledForActionTypeId, + SanitizedRuleAction, } from '@kbn/alerting-plugin/common'; jest.mock('../../../common/lib/kibana'); @@ -24,7 +25,7 @@ jest.mock('../../lib/action_connector_api', () => ({ loadAllActions: jest.fn(), loadActionTypes: jest.fn(), })); -const { loadActionTypes } = jest.requireMock('../../lib/action_connector_api'); +const { loadActionTypes, loadAllActions } = jest.requireMock('../../lib/action_connector_api'); const setHasActionsWithBrokenConnector = jest.fn(); describe('action_form', () => { @@ -106,6 +107,19 @@ describe('action_form', () => { actionParamsFields: mockedActionParamsFields, }; + const systemActionType = { + id: 'my-system-action-type', + iconClass: 'test', + selectMessage: 'system action', + validateParams: (): Promise> => { + const validationResult = { errors: {} }; + return Promise.resolve(validationResult); + }, + actionConnectorFields: null, + actionParamsFields: mockedActionParamsFields, + actionTypeTitle: 'system-action-type-title', + }; + const allActions = [ { secrets: {}, @@ -177,18 +191,28 @@ describe('action_form', () => { isPreconfigured: false, isDeprecated: false, }, + { + secrets: {}, + isMissingSecrets: false, + id: 'test', + actionTypeId: systemActionType.id, + name: 'Test system connector', + config: {}, + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true, + }, ]; const useKibanaMock = useKibana as jest.Mocked; async function setup( - customActions?: RuleAction[], + customActions?: RuleUiAction[], customRecoveredActionGroup?: string, isExperimental?: boolean ) { const actionTypeRegistry = actionTypeRegistryMock.create(); - const { loadAllActions } = jest.requireMock('../../lib/action_connector_api'); loadAllActions.mockResolvedValueOnce(allActions); const mocks = coreMock.createSetup(); const [ @@ -209,13 +233,16 @@ describe('action_form', () => { ...actionType, isExperimental, }; + actionTypeRegistry.list.mockReturnValue([ newActionType, disabledByConfigActionType, disabledByLicenseActionType, disabledByActionType, preconfiguredOnly, + systemActionType, ]); + actionTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.get.mockReturnValue(newActionType); const initialAlert = { @@ -226,7 +253,7 @@ describe('action_form', () => { schedule: { interval: '1m', }, - actions: customActions + actions: (customActions ? customActions : [ { @@ -237,12 +264,12 @@ describe('action_form', () => { message: '', }, }, - ], + ]) as SanitizedRuleAction[], tags: [], muteAll: false, enabled: false, mutedInstanceIds: [], - } as unknown as Rule; + }; loadActionTypes.mockResolvedValue([ { @@ -299,6 +326,16 @@ describe('action_form', () => { minimumLicenseRequired: 'basic', supportedFeatureIds: ['alerting'], }, + { + id: 'my-system-action-type', + name: 'System action', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: true, + }, ]); const defaultActionMessage = 'Alert [{{context.metadata.name}}] has exceeded the threshold'; @@ -338,14 +375,17 @@ describe('action_form', () => { setActionGroupIdByIndex={(group: string, index: number) => { initialAlert.actions[index].group = group; }} - setActions={(_updatedActions: RuleAction[]) => {}} + setActions={(_updatedActions: RuleUiAction[]) => {}} setActionParamsProperty={(key: string, value: any, index: number) => (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) } setActionFrequencyProperty={(key: string, value: any, index: number) => (initialAlert.actions[index] = { ...initialAlert.actions[index], - frequency: { ...initialAlert.actions[index].frequency!, [key]: value }, + frequency: { + ...initialAlert.actions[index].frequency!, + [key]: value, + }, }) } setActionAlertsFilterProperty={(key: string, value: any, index: number) => @@ -383,10 +423,18 @@ describe('action_form', () => { .find(`EuiToolTip [data-test-subj="${actionType.id}-alerting-ActionTypeSelectOption"]`) .exists() ).toBeFalsy(); + expect(setHasActionsWithBrokenConnector).toHaveBeenLastCalledWith(false); expect(loadActionTypes).toBeCalledWith( expect.objectContaining({ featureId: 'alerting', + includeSystemActions: true, + }) + ); + + expect(loadAllActions).toBeCalledWith( + expect.objectContaining({ + includeSystemActions: true, }) ); }); @@ -664,6 +712,7 @@ describe('action_form', () => { wrapper.find('EuiBetaBadge[data-test-subj="action-type-form-beta-badge"]').exists() ).toBeFalsy(); }); + it(`does not render beta badge when isExperimental=false`, async () => { const wrapper = await setup(undefined, undefined, false); expect(wrapper.find('EuiKeyPadMenuItem EuiBetaBadge').exists()).toBeFalsy(); @@ -671,6 +720,7 @@ describe('action_form', () => { wrapper.find('EuiBetaBadge[data-test-subj="action-type-form-beta-badge"]').exists() ).toBeFalsy(); }); + it(`renders beta badge when isExperimental=true`, async () => { const wrapper = await setup(undefined, undefined, true); expect(wrapper.find('EuiKeyPadMenuItem EuiBetaBadge').exists()).toBeTruthy(); @@ -679,4 +729,28 @@ describe('action_form', () => { ).toBeTruthy(); }); }); + + describe('system actions', () => { + it('renders system action types correctly', async () => { + const wrapper = await setup(); + const actionOption = wrapper.find( + `[data-test-subj="${systemActionType.id}-alerting-ActionTypeSelectOption"]` + ); + + expect(actionOption.exists()).toBeTruthy(); + expect(actionOption.at(1).prop('disabled')).toBe(false); + }); + + it('disables the system action type if it is already selected', async () => { + const wrapper = await setup([ + { id: 'system-connector-.cases', actionTypeId: systemActionType.id, params: {} }, + ]); + + const actionOption = wrapper.find( + `[data-test-subj="${systemActionType.id}-alerting-ActionTypeSelectOption"]` + ); + + expect(actionOption.at(1).prop('disabled')).toBe(true); + }); + }); }); 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 6d91e1837b830..097036a152de5 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 @@ -18,24 +18,28 @@ import { EuiKeyPadMenuItem, EuiToolTip, EuiLink, + EuiEmptyPrompt, + EuiText, } from '@elastic/eui'; import { ActionGroup, RuleActionAlertsFilterProperty, RuleActionFrequency, RuleActionParam, + RuleSystemAction, } from '@kbn/alerting-plugin/common'; import { v4 as uuidv4 } from 'uuid'; import { betaBadgeProps } from './beta_badge_props'; import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api'; import { ActionTypeModel, - RuleAction, ActionTypeIndex, ActionConnector, ActionVariables, ActionTypeRegistryContract, NotifyWhenSelectOptions, + RuleUiAction, + RuleAction, } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; import { ActionTypeForm } from './action_type_form'; @@ -47,21 +51,21 @@ import { useKibana } from '../../../common/lib/kibana'; import { ConnectorAddModal } from '.'; import { suspendedComponentWithProps } from '../../lib/suspended_component_with_props'; import { OmitMessageVariablesType } from '../../lib/action_variables'; +import { SystemActionTypeForm } from './system_action_type_form'; export interface ActionGroupWithMessageVariables extends ActionGroup { omitMessageVariables?: OmitMessageVariablesType; defaultActionMessage?: string; } - export interface ActionAccordionFormProps { - actions: RuleAction[]; + actions: RuleUiAction[]; defaultActionGroupId: string; actionGroups?: ActionGroupWithMessageVariables[]; defaultActionMessage?: string; setActionIdByIndex: (id: string, index: number) => void; setActionGroupIdByIndex?: (group: string, index: number) => void; setActionUseAlertDataForTemplate?: (enabled: boolean, index: number) => void; - setActions: (actions: RuleAction[]) => void; + setActions: (actions: RuleUiAction[]) => void; setActionParamsProperty: (key: string, value: RuleActionParam, index: number) => void; setActionFrequencyProperty: (key: string, value: RuleActionParam, index: number) => void; setActionAlertsFilterProperty: ( @@ -152,9 +156,13 @@ export const ActionForm = ({ (async () => { try { setIsLoadingActionTypes(true); - const registeredActionTypes = (await loadActionTypes({ http, featureId })).sort((a, b) => - a.name.localeCompare(b.name) - ); + const registeredActionTypes = ( + await loadActionTypes({ + http, + featureId, + includeSystemActions: true, + }) + ).sort((a, b) => a.name.localeCompare(b.name)); const index: ActionTypeIndex = {}; for (const actionTypeItem of registeredActionTypes) { index[actionTypeItem.id] = actionTypeItem; @@ -179,8 +187,12 @@ export const ActionForm = ({ (async () => { try { setIsLoadingConnectors(true); - const loadedConnectors = await loadConnectors({ http }); - setConnectors(loadedConnectors.filter((connector) => !connector.isMissingSecrets)); + const loadedConnectors = await loadConnectors({ http, includeSystemActions: true }); + setConnectors( + loadedConnectors.filter( + (connector) => !connector.isMissingSecrets || connector.isSystemAction + ) + ); } catch (e) { toasts.addDanger({ title: i18n.translate( @@ -240,54 +252,53 @@ export const ActionForm = ({ } setIsAddActionPanelOpen(false); const allowGroupConnector = (actionTypeModel?.subtype ?? []).map((atm) => atm.id); + const isSystemActionType = Boolean( + actionTypesIndex && actionTypesIndex[actionTypeModel.id]?.isSystemActionType + ); let actionTypeConnectors = connectors.filter( (field) => field.actionTypeId === actionTypeModel.id ); - if (actionTypeConnectors.length > 0) { - actions.push({ - id: '', - actionTypeId: actionTypeModel.id, - group: defaultActionGroupId, - params: {}, - frequency: defaultRuleFrequency, - uuid: uuidv4(), - }); - setActionIdByIndex(actionTypeConnectors[0].id, actions.length - 1); - } else { - actionTypeConnectors = connectors.filter((field) => - allowGroupConnector.includes(field.actionTypeId) - ); - if (actionTypeConnectors.length > 0) { - actions.push({ + const actionToPush = isSystemActionType + ? { id: '', - actionTypeId: actionTypeConnectors[0].actionTypeId, + actionTypeId: actionTypeModel.id, + params: {}, + uuid: uuidv4(), + } + : { + id: '', + actionTypeId: actionTypeModel.id, group: defaultActionGroupId, params: {}, - frequency: DEFAULT_FREQUENCY, + frequency: defaultRuleFrequency, uuid: uuidv4(), - }); - setActionIdByIndex(actionTypeConnectors[0].id, actions.length - 1); - } - } + }; if (actionTypeConnectors.length === 0) { - // if no connectors exists or all connectors is already assigned an action under current alert - // set actionType as id to be able to create new connector within the alert form - actions.push({ - id: '', - actionTypeId: actionTypeModel.id, - group: defaultActionGroupId, - params: {}, - frequency: defaultRuleFrequency, - }); - setActionIdByIndex(actions.length.toString(), actions.length - 1); - setEmptyActionsIds([...emptyActionsIds, actions.length.toString()]); + actionTypeConnectors = connectors.filter((field) => + allowGroupConnector.includes(field.actionTypeId) + ); + if (actionTypeConnectors.length > 0) { + // If a connector was successfully found, update the actionTypeId + actions.push({ ...actionToPush, actionTypeId: actionTypeConnectors[0].actionTypeId }); + setActionIdByIndex(actionTypeConnectors[0].id, actions.length - 1); + } else { + // if no connectors exists or all connectors is already assigned an action under current alert + // set actionType as id to be able to create new connector within the alert form + actions.push(actionToPush); + setActionIdByIndex(actions.length.toString(), actions.length - 1); + setEmptyActionsIds([...emptyActionsIds, actions.length.toString()]); + } + } else { + actions.push(actionToPush); + setActionIdByIndex(actionTypeConnectors[0].id, actions.length - 1); } } let actionTypeNodes: Array | null = null; let hasDisabledByLicenseActionTypes = false; + if (actionTypesIndex) { const preconfiguredConnectors = connectors.filter((connector) => connector.isPreconfigured); actionTypeNodes = actionTypeRegistry @@ -311,10 +322,17 @@ export const ActionForm = ({ hasDisabledByLicenseActionTypes = true; } + const isSystemActionSelected = Boolean( + actionTypesIndex[item.id].isSystemActionType && + actions.find((action) => action.actionTypeId === item.id) + ); + + const isDisabled = !checkEnabledResult.isEnabled || isSystemActionSelected; + const keyPadItem = ( )} {actionTypesIndex && - actions.map((actionItem: RuleAction, index: number) => { + actions.map((actionItem: RuleUiAction, index: number) => { + const isSystemActionType = Boolean( + actionTypesIndex[actionItem.actionTypeId]?.isSystemActionType + ); + const actionConnector = connectors.find((field) => field.id === actionItem.id); - // connectors doesn't exists + + const onDeleteAction = () => { + const updatedActions = actions.filter((_item: RuleUiAction, i: number) => i !== index); + setActions(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: RuleUiAction) => item.id !== actionItem.id).length === 0 + ); + setActiveActionItem(undefined); + }; + + if (isSystemActionType && !actionConnector) { + return ( + + + + } + /> + ); + } + // If connector does not exist if (!actionConnector) { return ( { - const updatedActions = actions.filter( - (_item: RuleAction, i: number) => i !== index - ); - setActions(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: RuleAction) => item.id !== actionItem.id) - .length === 0 - ); - setActiveActionItem(undefined); - }} + onDeleteConnector={onDeleteAction} onAddConnector={() => { setActiveActionItem({ actionTypeId: actionItem.actionTypeId, indices: actions - .map((item: RuleAction, idx: number) => + .map((item: RuleUiAction, idx: number) => item.id === actionItem.id ? idx : -1 ) .filter((idx: number) => idx >= 0), @@ -411,7 +447,7 @@ export const ActionForm = ({ if (newConnector && newConnector.actionTypeId) { const actionTypeRegistered = actionTypeRegistry.get(newConnector.actionTypeId); if (actionTypeRegistered.convertParamsBetweenGroups) { - const updatedActions = actions.map((_item: RuleAction, i: number) => { + const updatedActions = actions.map((_item: RuleUiAction, i: number) => { if (i === index) { return { ..._item, @@ -433,9 +469,33 @@ export const ActionForm = ({ ); } + if (isSystemActionType) { + return ( + + ); + } + return ( { + const updatedActions = actions.map((_item: RuleUiAction, i: number) => { if (i === index) { return { ..._item, @@ -488,7 +548,7 @@ export const ActionForm = ({ actionTypeRegistry={actionTypeRegistry} onDeleteAction={() => { const updatedActions = actions.filter( - (_item: RuleAction, i: number) => i !== index + (_item: RuleUiAction, i: number) => i !== index ); setActions(updatedActions); setIsAddActionPanelOpen(updatedActions.length === 0); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.test.tsx index a33f5714afb1e..1df0d90e6e6f9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.test.tsx @@ -9,14 +9,13 @@ import React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { EuiSuperSelectProps } from '@elastic/eui'; import { act } from 'react-dom/test-utils'; -import { RuleAction } from '../../../types'; import { ActionNotifyWhen } from './action_notify_when'; -import { RuleNotifyWhen } from '@kbn/alerting-plugin/common'; +import { RuleNotifyWhen, SanitizedRuleAction } from '@kbn/alerting-plugin/common'; import { DEFAULT_FREQUENCY } from '../../../common/constants'; describe('action_notify_when', () => { async function setup( - frequency: RuleAction['frequency'] = DEFAULT_FREQUENCY, + frequency: SanitizedRuleAction['frequency'] = DEFAULT_FREQUENCY, hasAlertsMappings: boolean = true ) { const wrapper = mountWithIntl( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.tsx index 16b9fee7e9233..6be43f797b7a4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_notify_when.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { RuleNotifyWhen } from '@kbn/alerting-plugin/common'; +import { RuleAction, RuleNotifyWhen } from '@kbn/alerting-plugin/common'; import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -27,7 +27,7 @@ import { import { some, filter, map } from 'fp-ts/lib/Option'; import { pipe } from 'fp-ts/lib/pipeable'; import { getTimeOptions } from '../../../common/lib/get_time_options'; -import { RuleNotifyWhenType, RuleAction, NotifyWhenSelectOptions } from '../../../types'; +import { RuleNotifyWhenType, NotifyWhenSelectOptions } from '../../../types'; import { DEFAULT_FREQUENCY } from '../../../common/constants'; export const NOTIFY_WHEN_OPTIONS: NotifyWhenSelectOptions[] = [ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx index b7bdb57a4d656..cbf6c17e78481 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx @@ -11,7 +11,6 @@ import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ActionConnector, ActionType, - RuleAction, GenericValidationResult, ActionConnectorMode, ActionVariables, @@ -23,7 +22,11 @@ import { I18nProvider, __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render, waitFor, screen } from '@testing-library/react'; import { DEFAULT_FREQUENCY } from '../../../common/constants'; import { transformActionVariables } from '../../lib/action_variables'; -import { RuleNotifyWhen, RuleNotifyWhenType } from '@kbn/alerting-plugin/common'; +import { + RuleNotifyWhen, + RuleNotifyWhenType, + SanitizedRuleAction, +} from '@kbn/alerting-plugin/common'; import { AlertConsumers } from '@kbn/rule-data-utils'; const CUSTOM_NOTIFY_WHEN_OPTIONS: NotifyWhenSelectOptions[] = [ @@ -654,7 +657,7 @@ function getActionTypeForm({ }: { index?: number; actionConnector?: ActionConnector, Record>; - actionItem?: RuleAction; + actionItem?: SanitizedRuleAction; defaultActionGroupId?: string; connectors?: Array, Record>>; actionTypeIndex?: Record; @@ -686,7 +689,7 @@ function getActionTypeForm({ secrets: {}, }; - const actionItemDefault: RuleAction = { + const actionItemDefault = { id: '123', actionTypeId: '.pagerduty', group: 'trigger', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx index ec5e4ef01ceec..7811b1b88b6bb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx @@ -25,16 +25,16 @@ import { EuiBetaBadge, } from '@elastic/eui'; import { betaBadgeProps } from './beta_badge_props'; -import { RuleAction, ActionTypeIndex, ActionConnector } from '../../../types'; +import { RuleUiAction, ActionTypeIndex, ActionConnector } from '../../../types'; import { hasSaveActionsCapability } from '../../lib/capabilities'; import { ActionAccordionFormProps } from './action_form'; import { useKibana } from '../../../common/lib/kibana'; import { getValidConnectors } from '../common/connectors'; import { ConnectorsSelection } from './connectors_selection'; -type AddConnectorInFormProps = { +export type AddConnectorInFormProps = { actionTypesIndex: ActionTypeIndex; - actionItem: RuleAction; + actionItem: RuleUiAction; connectors: ActionConnector[]; index: number; onAddConnector: () => void; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connectors_selection.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connectors_selection.tsx index 736d8c44ea927..6554709846d4c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connectors_selection.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connectors_selection.tsx @@ -9,7 +9,7 @@ import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { ActionConnector, ActionTypeIndex, ActionTypeModel, RuleAction } from '../../../types'; +import { ActionConnector, ActionTypeIndex, ActionTypeModel, RuleUiAction } from '../../../types'; import { getValidConnectors } from '../common/connectors'; interface ConnectorOption { @@ -20,7 +20,7 @@ interface ConnectorOption { interface SelectionProps { allowGroupConnector?: string[]; - actionItem: RuleAction; + actionItem: RuleUiAction; accordionIndex: number; actionTypesIndex: ActionTypeIndex; actionTypeRegistered: ActionTypeModel; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/system_action_type_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/system_action_type_form.test.tsx new file mode 100644 index 0000000000000..ef92d61fbc303 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/system_action_type_form.test.tsx @@ -0,0 +1,212 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { SystemActionTypeForm } from './system_action_type_form'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ActionType, GenericValidationResult, ActionParamsProps } from '../../../types'; +import { EuiButton } from '@elastic/eui'; +import { AlertConsumers } from '@kbn/rule-data-utils'; +import { I18nProvider } from '@kbn/i18n-react'; +import userEvent from '@testing-library/user-event'; + +const actionTypeRegistry = actionTypeRegistryMock.create(); + +jest.mock('../../../common/lib/kibana'); + +jest.mock('../../lib/action_variables', () => { + const original = jest.requireActual('../../lib/action_variables'); + return { + ...original, + transformActionVariables: jest.fn(), + }; +}); + +jest.mock('../../hooks/use_rule_aad_template_fields', () => ({ + useRuleTypeAadTemplateFields: () => ({ + isLoading: false, + fields: [], + }), +})); + +const actionConnector = { + actionTypeId: '.test-system-action', + config: {}, + id: 'test', + isPreconfigured: false as const, + isDeprecated: false, + isSystemAction: true, + name: 'test name', + secrets: {}, +}; + +const actionItem = { + id: '123', + actionTypeId: '.test-system-action', + params: {}, +}; + +const connectors = [actionConnector]; + +const actionTypeIndexDefault: Record = { + '.test-system-action': { + id: '.test-system-action', + enabled: true, + name: 'Test', + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + supportedFeatureIds: ['alerting'], + isSystemActionType: true, + }, +}; + +const mockedActionParamsFields = React.lazy(async () => ({ + default(props: ActionParamsProps<{}>) { + return ( + <> + { + props.editAction('my-key', 'my-value', 1); + }} + /> + + ); + }, +})); + +describe('action_type_form', () => { + beforeEach(() => { + const actionType = actionTypeRegistryMock.createMockActionTypeModel({ + id: '.test-system-action', + iconClass: 'test', + selectMessage: 'test', + validateParams: (): Promise> => { + const validationResult = { errors: {} }; + return Promise.resolve(validationResult); + }, + actionConnectorFields: null, + actionParamsFields: mockedActionParamsFields, + defaultActionParams: { + dedupKey: 'test', + eventAction: 'resolve', + }, + isSystemActionType: true, + }); + + actionTypeRegistry.get.mockReturnValue(actionType); + + jest.clearAllMocks(); + }); + + it('should render the system action correctly', async () => { + render( + + + + ); + + expect(await screen.findByTestId('test-button')).toBeInTheDocument(); + }); + + it('should render the name of the system action correctly', async () => { + render( + + + + ); + + expect(await screen.findByText('test name')).toBeInTheDocument(); + }); + + it('calls onDeleteAction correctly', async () => { + const onDelete = jest.fn(); + + render( + + + + ); + + userEvent.click(await screen.findByTestId('system-action-delete-button')); + + await waitFor(() => { + expect(onDelete).toHaveBeenCalled(); + }); + }); + + it('calls setActionParamsProperty correctly', async () => { + const setActionParamsProperty = jest.fn(); + + render( + + + + ); + + userEvent.click(await screen.findByTestId('test-button')); + + expect(setActionParamsProperty).toHaveBeenCalledWith('my-key', 'my-value', 1); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/system_action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/system_action_type_form.tsx new file mode 100644 index 0000000000000..62263a7f87ad4 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/system_action_type_form.tsx @@ -0,0 +1,355 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiAccordion, + EuiButtonIcon, + EuiText, + EuiBadge, + EuiErrorBoundary, + EuiToolTip, + EuiBetaBadge, + EuiSplitPanel, + EuiCallOut, + IconType, +} from '@elastic/eui'; +import { isEmpty, partition, some } from 'lodash'; +import { ActionVariable, RuleActionParam } from '@kbn/alerting-plugin/common'; +import { betaBadgeProps } from './beta_badge_props'; +import { + IErrorObject, + RuleSystemAction, + ActionTypeIndex, + ActionConnector, + ActionVariables, + ActionTypeRegistryContract, + ActionConnectorMode, +} from '../../../types'; +import { ActionAccordionFormProps, ActionGroupWithMessageVariables } from './action_form'; +import { transformActionVariables } from '../../lib/action_variables'; +import { useKibana } from '../../../common/lib/kibana'; +import { validateParamsForWarnings } from '../../lib/validate_params_for_warnings'; +import { useRuleTypeAadTemplateFields } from '../../hooks/use_rule_aad_template_fields'; + +export type SystemActionTypeFormProps = { + actionItem: RuleSystemAction; + actionConnector: ActionConnector; + index: number; + onDeleteAction: () => void; + setActionParamsProperty: (key: string, value: RuleActionParam, index: number) => void; + actionTypesIndex: ActionTypeIndex; + connectors: ActionConnector[]; + actionTypeRegistry: ActionTypeRegistryContract; + featureId: string; + producerId: string; + ruleTypeId?: string; + disableErrorMessages?: boolean; +} & Pick< + ActionAccordionFormProps, + | 'setActionParamsProperty' + | 'messageVariables' + | 'summaryMessageVariables' + | 'defaultActionMessage' + | 'defaultSummaryMessage' +>; + +export const SystemActionTypeForm = ({ + actionItem, + actionConnector, + index, + onDeleteAction, + setActionParamsProperty, + actionTypesIndex, + connectors, + defaultActionMessage, + messageVariables, + summaryMessageVariables, + actionTypeRegistry, + defaultSummaryMessage, + producerId, + featureId, + ruleTypeId, + disableErrorMessages, +}: SystemActionTypeFormProps) => { + const { http } = useKibana().services; + const [isOpen, setIsOpen] = useState(true); + const [actionParamsErrors, setActionParamsErrors] = useState<{ errors: IErrorObject }>({ + errors: {}, + }); + + const [warning, setWarning] = useState(null); + + const { fields: aadTemplateFields } = useRuleTypeAadTemplateFields(http, ruleTypeId, true); + + const getDefaultParams = useCallback(() => { + const connectorType = actionTypeRegistry.get(actionItem.actionTypeId); + + return connectorType.defaultActionParams; + }, [actionItem.actionTypeId, actionTypeRegistry]); + + const availableActionVariables = useMemo( + () => + messageVariables + ? getAvailableActionVariables(messageVariables, summaryMessageVariables, undefined, true) + : [], + [messageVariables, summaryMessageVariables] + ); + + useEffect(() => { + const defaultParams = getDefaultParams(); + + if (defaultParams) { + for (const [key, paramValue] of Object.entries(defaultParams)) { + const defaultAADParams: typeof defaultParams = {}; + if (actionItem.params[key] === undefined || actionItem.params[key] === null) { + setActionParamsProperty(key, paramValue, index); + // Add default param to AAD defaults only if it does not contain any template code + if (typeof paramValue !== 'string' || !paramValue.match(/{{.*?}}/g)) { + defaultAADParams[key] = paramValue; + } + } + } + } + }, [ + actionItem.params, + getDefaultParams, + index, + messageVariables, + setActionParamsProperty, + summaryMessageVariables, + ]); + + useEffect(() => { + const defaultParams = getDefaultParams(); + + if (defaultParams) { + const defaultAADParams: typeof defaultParams = {}; + for (const [key, paramValue] of Object.entries(defaultParams)) { + setActionParamsProperty(key, paramValue, index); + if (!paramValue.match(/{{.*?}}/g)) { + defaultAADParams[key] = paramValue; + } + } + } + }, [getDefaultParams, index, setActionParamsProperty]); + + useEffect(() => { + (async () => { + if (disableErrorMessages) { + setActionParamsErrors({ errors: {} }); + return; + } + const res: { errors: IErrorObject } = await actionTypeRegistry + .get(actionItem.actionTypeId) + ?.validateParams(actionItem.params); + setActionParamsErrors(res); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionItem, disableErrorMessages]); + + const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); + if (!actionTypeRegistered) return null; + + const showActionGroupErrorIcon = (): boolean => { + return !isOpen && some(actionParamsErrors.errors, (error) => !isEmpty(error)); + }; + + const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields; + + const accordionContent = ( + <> + + {ParamsFieldsComponent ? ( + + + + + { + setWarning( + validateParamsForWarnings( + value, + http.basePath.publicBaseUrl, + availableActionVariables + ) + ); + setActionParamsProperty(key, value, i); + }} + messageVariables={aadTemplateFields} + defaultMessage={defaultSummaryMessage} + useDefaultMessage={true} + actionConnector={actionConnector} + executionMode={ActionConnectorMode.ActionForm} + ruleTypeId={ruleTypeId} + /> + {warning ? ( + <> + + + + ) : null} + + + + + ) : null} + + + ); + + return ( + <> + + + } + extraAction={ + + } + > + {accordionContent} + + + + + ); +}; + +function getAvailableActionVariables( + actionVariables: ActionVariables, + summaryActionVariables?: ActionVariables, + actionGroup?: ActionGroupWithMessageVariables, + isSummaryAction?: boolean +) { + const transformedActionVariables: ActionVariable[] = transformActionVariables( + actionVariables, + summaryActionVariables, + actionGroup?.omitMessageVariables, + isSummaryAction + ); + + // partition deprecated items so they show up last + const partitionedActionVariables = partition( + transformedActionVariables, + (v) => v.deprecated !== true + ); + return partitionedActionVariables.reduce((acc, curr) => { + return [ + ...acc, + ...curr.sort((a, b) => a.name.toUpperCase().localeCompare(b.name.toUpperCase())), + ]; + }, []); +} + +const ButtonContent: React.FC<{ + showActionGroupErrorIcon: boolean; + iconClass: string | IconType; + connectorName: string; + showWarning: boolean; + isExperimental: boolean; +}> = ({ showActionGroupErrorIcon, iconClass, showWarning, isExperimental, connectorName }) => { + return ( + + {showActionGroupErrorIcon ? ( + + + + + + ) : ( + + + + )} + + +
+ + + + + {showWarning && ( + + + {i18n.translate( + 'xpack.triggersActionsUI.sections.actionTypeForm.actionWarningsTitle', + { + defaultMessage: '1 warning', + } + )} + + + )} + +
+
+
+ {isExperimental && ( + + + + )} +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx index dac08978a54c9..888e42524bf7c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.test.tsx @@ -41,6 +41,6 @@ describe('with_action_api_operations', () => { component.find('button').simulate('click'); expect(actionApis.loadActionTypes).toHaveBeenCalledTimes(1); - expect(actionApis.loadActionTypes).toHaveBeenCalledWith({ http }); + expect(actionApis.loadActionTypes).toHaveBeenCalledWith({ http, includeSystemActions: true }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx index 7b2233ded244c..1ba823dc3c41d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_actions_api_operations.tsx @@ -40,7 +40,7 @@ export function withActionOperations( return ( loadActionTypes({ http })} + loadActionTypes={async () => loadActionTypes({ http, includeSystemActions: true })} loadGlobalConnectorExecutionLogAggregations={async ( loadProps: LoadGlobalConnectorExecutionLogAggregationsProps ) => diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts index af09d984f9417..3ce8e1e178f57 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { ActionConnector, ActionTypeIndex, RuleAction } from '../../../types'; +import { ActionConnector, ActionTypeIndex, RuleUiAction } from '../../../types'; export const getValidConnectors = ( connectors: ActionConnector[], - actionItem: RuleAction, + actionItem: RuleUiAction, actionTypesIndex: ActionTypeIndex, allowGroupConnector: string[] = [] ): ActionConnector[] => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.test.tsx index 6e6aee64aaf58..a27241d9cef9b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.test.tsx @@ -8,17 +8,30 @@ import React from 'react'; import { mount } from 'enzyme'; import { nextTick } from '@kbn/test-jest-helpers'; import { act } from 'react-dom/test-utils'; +import { screen, render } from '@testing-library/react'; import { RuleActions } from './rule_actions'; import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; -import { ActionConnector, ActionTypeModel, RuleAction } from '../../../../types'; +import { ActionConnector, ActionTypeModel } from '../../../../types'; import * as useFetchRuleActionConnectorsHook from '../../../hooks/use_fetch_rule_action_connectors'; const actionTypeRegistry = actionTypeRegistryMock.create(); +const actionType = { + id: 'test', + name: 'Test', + isSystemActionType: false, +} as unknown as ActionTypeModel; + const mockedUseFetchRuleActionConnectorsHook = jest.spyOn( useFetchRuleActionConnectorsHook, 'useFetchRuleActionConnectors' ); + describe('Rule Actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + actionTypeRegistry.get.mockReturnValue(actionType); + }); + async function setup() { const ruleActions = [ { @@ -33,7 +46,7 @@ describe('Rule Actions', () => { actionTypeId: '.slack', params: {}, }, - ] as RuleAction[]; + ]; mockedUseFetchRuleActionConnectorsHook.mockReturnValue({ isLoadingActionConnectors: false, @@ -169,4 +182,40 @@ describe('Rule Actions', () => { expect(wrapper.find('[data-test-subj="actionConnectorName-3-slack1"]').exists).toBeTruthy(); expect(wrapper.find('[data-test-subj="actionConnectorName-4-slack2"]').exists).toBeTruthy(); }); + + it('shows the correct notify text for system actions', async () => { + const ruleActions = [ + { + id: 'system-connector-.test-system-action', + actionTypeId: '.test-system-action', + params: {}, + }, + ]; + + actionTypeRegistry.list.mockReturnValue([ + { id: '.test-system-action', iconClass: 'logsApp' }, + ] as ActionTypeModel[]); + + actionTypeRegistry.get.mockReturnValue({ + ...actionType, + isSystemActionType: true, + id: '.test-system-action', + }); + + mockedUseFetchRuleActionConnectorsHook.mockReturnValue({ + isLoadingActionConnectors: false, + actionConnectors: [ + { + id: 'system-connector-.test-system-action', + actionTypeId: '.test-system-action', + }, + ] as Array>>, + errorActionConnectors: undefined, + reloadRuleActionConnectors: jest.fn(), + }); + + render(); + + expect(await screen.findByText('On check intervals')).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx index 6ff952e7f30bb..a4e0ea59e685c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx @@ -16,12 +16,13 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { RuleNotifyWhenType } from '@kbn/alerting-plugin/common'; -import { ActionTypeRegistryContract, RuleAction, suspendedComponentWithProps } from '../../../..'; +import { ActionTypeRegistryContract, suspendedComponentWithProps } from '../../../..'; import { useFetchRuleActionConnectors } from '../../../hooks/use_fetch_rule_action_connectors'; import { NOTIFY_WHEN_OPTIONS } from '../../rule_form/rule_notify_when'; +import { RuleUiAction } from '../../../../types'; export interface RuleActionsProps { - ruleActions: RuleAction[]; + ruleActions: RuleUiAction[]; actionTypeRegistry: ActionTypeRegistryContract; legacyNotifyWhen?: RuleNotifyWhenType | null; } @@ -51,11 +52,19 @@ export function RuleActions({ ); } - const getNotifyText = (action: RuleAction) => - (NOTIFY_WHEN_OPTIONS.find((options) => options.value === action.frequency?.notifyWhen) - ?.inputDisplay || - action.frequency?.notifyWhen) ?? - legacyNotifyWhen; + const getNotifyText = (action: RuleUiAction, isSystemAction?: boolean) => { + if (isSystemAction) { + return NOTIFY_WHEN_OPTIONS[1].inputDisplay; + } + + return ( + ('frequency' in action && + (NOTIFY_WHEN_OPTIONS.find((options) => options.value === action.frequency?.notifyWhen) + ?.inputDisplay || + action.frequency?.notifyWhen)) ?? + legacyNotifyWhen + ); + }; const getActionIconClass = (actionGroupId?: string): IconType | undefined => { const actionGroup = actionTypeRegistry.list().find((group) => group.id === actionGroupId); @@ -70,6 +79,7 @@ export function RuleActions({ }; if (isLoadingActionConnectors) return ; + return ( {ruleActions.map((action, index) => { @@ -98,7 +108,12 @@ export function RuleActions({ data-test-subj={`actionConnectorName-${index}-${actionName || actionTypeId}`} size="xs" > - {String(getNotifyText(action))} + {String( + getNotifyText( + action, + actionTypeRegistry.get(actionTypeId).isSystemActionType + ) + )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index a7f46a8e554c5..8bb60c0706f15 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; -import React, { useState, useEffect, useReducer } from 'react'; +import React, { useState, useEffect, useReducer, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; import { EuiPageHeader, @@ -58,7 +58,7 @@ import { rulesWarningReasonTranslationsMapping, } from '../../rules_list/translations'; import { useKibana } from '../../../../common/lib/kibana'; -import { ruleReducer } from '../../rule_form/rule_reducer'; +import { getRuleReducer } from '../../rule_form/rule_reducer'; import { loadAllActions as loadConnectors } from '../../../lib/action_connector_api'; import { triggersActionsUiConfig } from '../../../../common/lib/config_api'; import { runRule } from '../../../lib/run_rule'; @@ -109,6 +109,7 @@ export const RuleDetails: React.FunctionComponent = ({ http, notifications: { toasts }, } = useKibana().services; + const ruleReducer = useMemo(() => getRuleReducer(actionTypeRegistry), [actionTypeRegistry]); const [{}, dispatch] = useReducer(ruleReducer, { rule }); const setInitialRule = (value: Rule) => { dispatch({ command: { type: 'setRule' }, payload: { key: 'rule', value } }); @@ -141,7 +142,7 @@ export const RuleDetails: React.FunctionComponent = ({ (async () => { let loadedConnectors: ActionConnector[] = []; try { - loadedConnectors = await loadConnectors({ http }); + loadedConnectors = await loadConnectors({ http, includeSystemActions: true }); } catch (err) { loadedConnectors = []; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx index 19eb8da4bf0d3..1b12fbffa4a1b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx @@ -26,7 +26,7 @@ import { } from '../../../types'; import { RuleForm } from './rule_form'; import { getRuleActionErrors, getRuleErrors, isValidRule } from './rule_errors'; -import { ruleReducer, InitialRule, InitialRuleReducer } from './rule_reducer'; +import { InitialRule, getRuleReducer } from './rule_reducer'; import { createRule } from '../../lib/rule_api/create'; import { loadRuleTypes } from '../../lib/rule_api/rule_types'; import { HealthCheck } from '../../components/health_check'; @@ -91,7 +91,8 @@ const RuleAdd = < ...(initialValues ? initialValues : {}), }; }, [ruleTypeId, consumer, initialValues]); - const [{ rule }, dispatch] = useReducer(ruleReducer as InitialRuleReducer, { + const ruleReducer = useMemo(() => getRuleReducer(actionTypeRegistry), [actionTypeRegistry]); + const [{ rule }, dispatch] = useReducer(ruleReducer, { rule: initialRule, }); const [config, setConfig] = useState({ isUsingSecurity: false }); @@ -234,9 +235,10 @@ const RuleAdd = < : {}), } as Rule, ruleType, - config + config, + actionTypeRegistry ), - [rule, selectedConsumer, selectableConsumer, ruleType, config] + [rule, selectableConsumer, selectedConsumer, ruleType, config, actionTypeRegistry] ); // Confirm before saving if user is able to add actions but hasn't added any to this rule diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx index 975881e516e45..e4fcd86eef445 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_edit.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useReducer, useState, useEffect, useCallback } from 'react'; +import React, { useReducer, useState, useEffect, useCallback, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { RuleNotifyWhen } from '@kbn/alerting-plugin/common'; import { @@ -38,10 +38,12 @@ import { RuleTypeMetaData, TriggersActionsUiConfig, RuleNotifyWhenType, + RuleUiAction, + RuleAction, } from '../../../types'; import { RuleForm } from './rule_form'; import { getRuleActionErrors, getRuleErrors, isValidRule } from './rule_errors'; -import { ruleReducer, ConcreteRuleReducer } from './rule_reducer'; +import { getRuleReducer } from './rule_reducer'; import { updateRule } from '../../lib/rule_api/update'; import { loadRuleTypes } from '../../lib/rule_api/rule_types'; import { HealthCheck } from '../../components/health_check'; @@ -60,6 +62,14 @@ const defaultUpdateRuleErrorMessage = i18n.translate( } ); +// Separate function for determining if an untyped action has a group property or not, which helps determine if +// it is a default action or a system action. Consolidated here to deal with type definition complexity +const actionHasDefinedGroup = (action: RuleUiAction): action is RuleAction => { + if (!('group' in action)) return false; + // If the group property is present, ensure that it isn't null or undefined + return Boolean(action.group); +}; + const cloneAndMigrateRule = (initialRule: Rule) => { const clonedRule = cloneDeep(omit(initialRule, 'notifyWhen', 'throttle')); @@ -75,10 +85,16 @@ const cloneAndMigrateRule = (initialRule: Rule) => { initialRule.notifyWhen === RuleNotifyWhen.THROTTLE ? initialRule.throttle! : null, } : { summary: false, notifyWhen: RuleNotifyWhen.THROTTLE, throttle: initialRule.throttle! }; - clonedRule.actions = clonedRule.actions.map((action) => ({ - ...action, - frequency, - })); + + clonedRule.actions = clonedRule.actions.map((action: RuleUiAction) => { + if (actionHasDefinedGroup(action)) { + return { + ...action, + frequency, + }; + } + return action; + }); } return clonedRule; }; @@ -100,7 +116,8 @@ export const RuleEdit = < ...props }: RuleEditProps) => { const onSaveHandler = onSave ?? reloadRules; - const [{ rule }, dispatch] = useReducer(ruleReducer as ConcreteRuleReducer, { + const ruleReducer = useMemo(() => getRuleReducer(actionTypeRegistry), [actionTypeRegistry]); + const [{ rule }, dispatch] = useReducer(ruleReducer, { rule: cloneAndMigrateRule(initialRule), }); const [isSaving, setIsSaving] = useState(false); @@ -160,7 +177,8 @@ export const RuleEdit = < const { ruleBaseErrors, ruleErrors, ruleParamsErrors } = getRuleErrors( rule as Rule, ruleType, - config + config, + actionTypeRegistry ); const checkForChangesAndCloseFlyout = () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.test.tsx index ad4f081e3d625..4457c2daa1038 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.test.tsx @@ -16,14 +16,16 @@ import { } from './rule_errors'; import { Rule, RuleTypeModel } from '../../../types'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ActionTypeModel } from '../../..'; +const actionTypeRegistry = actionTypeRegistryMock.create(); const config = { isUsingSecurity: true, minimumScheduleInterval: { value: '1m', enforce: false } }; describe('rule_errors', () => { describe('validateBaseProperties()', () => { it('should validate the name', () => { const rule = mockRule(); rule.name = ''; - const result = validateBaseProperties(rule, config); + const result = validateBaseProperties(rule, config, actionTypeRegistry); expect(result.errors).toStrictEqual({ name: ['Name is required.'], 'schedule.interval': [], @@ -36,7 +38,7 @@ describe('rule_errors', () => { it('should validate the interval', () => { const rule = mockRule(); rule.schedule.interval = ''; - const result = validateBaseProperties(rule, config); + const result = validateBaseProperties(rule, config, actionTypeRegistry); expect(result.errors).toStrictEqual({ name: [], 'schedule.interval': ['Check interval is required.'], @@ -49,7 +51,7 @@ describe('rule_errors', () => { it('should validate the minimumScheduleInterval if enforce = false', () => { const rule = mockRule(); rule.schedule.interval = '2s'; - const result = validateBaseProperties(rule, config); + const result = validateBaseProperties(rule, config, actionTypeRegistry); expect(result.errors).toStrictEqual({ name: [], 'schedule.interval': [], @@ -62,10 +64,14 @@ describe('rule_errors', () => { it('should validate the minimumScheduleInterval if enforce = true', () => { const rule = mockRule(); rule.schedule.interval = '2s'; - const result = validateBaseProperties(rule, { - isUsingSecurity: true, - minimumScheduleInterval: { value: '1m', enforce: true }, - }); + const result = validateBaseProperties( + rule, + { + isUsingSecurity: true, + minimumScheduleInterval: { value: '1m', enforce: true }, + }, + actionTypeRegistry + ); expect(result.errors).toStrictEqual({ name: [], 'schedule.interval': ['Interval must be at least 1 minute.'], @@ -78,7 +84,7 @@ describe('rule_errors', () => { it('should validate the ruleTypeId', () => { const rule = mockRule(); rule.ruleTypeId = ''; - const result = validateBaseProperties(rule, config); + const result = validateBaseProperties(rule, config, actionTypeRegistry); expect(result.errors).toStrictEqual({ name: [], 'schedule.interval': [], @@ -91,7 +97,7 @@ describe('rule_errors', () => { it('should get an error when consumer is null', () => { const rule = mockRule(); rule.consumer = null as unknown as string; - const result = validateBaseProperties(rule, config); + const result = validateBaseProperties(rule, config, actionTypeRegistry); expect(result.errors).toStrictEqual({ name: [], 'schedule.interval': [], @@ -104,7 +110,7 @@ describe('rule_errors', () => { it('should not get an error when consumer is undefined', () => { const rule = mockRule(); rule.consumer = undefined as unknown as string; - const result = validateBaseProperties(rule, config); + const result = validateBaseProperties(rule, config, actionTypeRegistry); expect(result.errors).toStrictEqual({ name: [], 'schedule.interval': [], @@ -126,7 +132,13 @@ describe('rule_errors', () => { }, }, ]; - const result = validateBaseProperties(rule, config); + const actionType = { + id: 'test', + name: 'Test', + isSystemActionType: false, + } as unknown as ActionTypeModel; + actionTypeRegistry.get.mockReturnValue(actionType); + const result = validateBaseProperties(rule, config, actionTypeRegistry); expect(result.errors).toStrictEqual({ name: [], 'schedule.interval': [], @@ -135,6 +147,35 @@ describe('rule_errors', () => { consumer: [], }); }); + + it('should not throw an error for system actions', () => { + const rule = mockRule(); + + rule.actions = [ + { + id: '1234', + actionTypeId: '.test-system-action', + params: {}, + }, + ]; + + const actionType = { + id: '.test-system-action', + name: 'Test', + isSystemActionType: true, + } as unknown as ActionTypeModel; + + actionTypeRegistry.get.mockReturnValue(actionType); + const result = validateBaseProperties(rule, config, actionTypeRegistry); + + expect(result.errors).toStrictEqual({ + name: [], + 'schedule.interval': [], + ruleTypeId: [], + actionConnectors: [], + consumer: [], + }); + }); }); describe('getRuleErrors()', () => { @@ -150,7 +191,8 @@ describe('rule_errors', () => { }, }), }), - config + config, + actionTypeRegistry ); expect(result).toStrictEqual({ ruleParamsErrors: { field: ['This is wrong'] }, @@ -175,7 +217,6 @@ describe('rule_errors', () => { describe('getRuleActionErrors()', () => { it('should return an array of errors', async () => { - const actionTypeRegistry = actionTypeRegistryMock.create(); actionTypeRegistry.get.mockImplementation((actionTypeId: string) => ({ ...actionTypeRegistryMock.createMockActionTypeModel(), validateParams: jest.fn().mockImplementation(() => ({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.ts index 0b5c511843472..2577474d35691 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_errors.ts @@ -6,24 +6,26 @@ */ import { isObject } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { RuleNotifyWhen } from '@kbn/alerting-plugin/common'; +import { RuleNotifyWhen, SanitizedRuleAction } from '@kbn/alerting-plugin/common'; import { formatDuration, parseDuration } from '@kbn/alerting-plugin/common/parse_duration'; import { RuleTypeModel, Rule, IErrorObject, - RuleAction, ValidationResult, ActionTypeRegistryContract, TriggersActionsUiConfig, + RuleUiAction, } from '../../../types'; import { InitialRule } from './rule_reducer'; export function validateBaseProperties( ruleObject: InitialRule, - config: TriggersActionsUiConfig + config: TriggersActionsUiConfig, + actionTypeRegistry: ActionTypeRegistryContract ): ValidationResult { const validationResult = { errors: {} }; + const errors = { name: new Array(), 'schedule.interval': new Array(), @@ -31,7 +33,9 @@ export function validateBaseProperties( ruleTypeId: new Array(), actionConnectors: new Array(), }; + validationResult.errors = errors; + if (!ruleObject.name) { errors.name.push( i18n.translate('xpack.triggersActionsUI.sections.ruleForm.error.requiredNameText', { @@ -39,6 +43,7 @@ export function validateBaseProperties( }) ); } + if (ruleObject.consumer === null) { errors.consumer.push( i18n.translate('xpack.triggersActionsUI.sections.ruleForm.error.requiredConsumerText', { @@ -46,6 +51,7 @@ export function validateBaseProperties( }) ); } + if (ruleObject.schedule.interval.length < 2) { errors['schedule.interval'].push( i18n.translate('xpack.triggersActionsUI.sections.ruleForm.error.requiredIntervalText', { @@ -68,16 +74,23 @@ export function validateBaseProperties( } const invalidThrottleActions = ruleObject.actions.filter((a) => { - if (!a.frequency?.throttle) return false; - const throttleDuration = parseDuration(a.frequency.throttle); + if (actionTypeRegistry.get(a.actionTypeId).isSystemActionType) return false; + + const defaultAction = a as SanitizedRuleAction; + if (!defaultAction.frequency?.throttle) return false; + + const throttleDuration = parseDuration(defaultAction.frequency.throttle); const intervalDuration = ruleObject.schedule.interval && ruleObject.schedule.interval.length > 1 ? parseDuration(ruleObject.schedule.interval) : 0; + return ( - a.frequency?.notifyWhen === RuleNotifyWhen.THROTTLE && throttleDuration < intervalDuration + defaultAction.frequency?.notifyWhen === RuleNotifyWhen.THROTTLE && + throttleDuration < intervalDuration ); }); + if (invalidThrottleActions.length) { errors['schedule.interval'].push( i18n.translate( @@ -97,9 +110,11 @@ export function validateBaseProperties( }) ); } + const emptyConnectorActions = ruleObject.actions.find( (actionItem) => /^\d+$/.test(actionItem.id) && Object.keys(actionItem.params).length > 0 ); + if (emptyConnectorActions !== undefined) { errors.actionConnectors.push( i18n.translate('xpack.triggersActionsUI.sections.ruleForm.error.requiredActionConnector', { @@ -108,18 +123,23 @@ export function validateBaseProperties( }) ); } + return validationResult; } export function getRuleErrors( rule: Rule, ruleTypeModel: RuleTypeModel | null, - config: TriggersActionsUiConfig + config: TriggersActionsUiConfig, + actionTypeRegistry: ActionTypeRegistryContract ) { const ruleParamsErrors: IErrorObject = ruleTypeModel ? ruleTypeModel.validate(rule.params).errors - : []; - const ruleBaseErrors = validateBaseProperties(rule, config).errors as IErrorObject; + : {}; + + const ruleBaseErrors = validateBaseProperties(rule, config, actionTypeRegistry) + .errors as IErrorObject; + const ruleErrors = { ...ruleParamsErrors, ...ruleBaseErrors, @@ -133,12 +153,12 @@ export function getRuleErrors( } export async function getRuleActionErrors( - actions: RuleAction[], + actions: RuleUiAction[], actionTypeRegistry: ActionTypeRegistryContract ): Promise { return await Promise.all( actions.map( - async (ruleAction: RuleAction) => + async (ruleAction: RuleUiAction) => ( await actionTypeRegistry.get(ruleAction.actionTypeId)?.validateParams(ruleAction.params) ).errors diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx index 83035607cb725..6319d75c5eaf7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form.tsx @@ -58,6 +58,7 @@ import { RecoveredActionGroup, isActionGroupDisabledForActionTypeId, RuleActionAlertsFilterProperty, + RuleActionKey, } from '@kbn/alerting-plugin/common'; import { AlertingConnectorFeatureId } from '@kbn/actions-plugin/common'; import { AlertConsumers } from '@kbn/rule-data-utils'; @@ -66,12 +67,12 @@ import { RuleTypeModel, Rule, IErrorObject, - RuleAction, RuleType, RuleTypeRegistryContract, ActionTypeRegistryContract, TriggersActionsUiConfig, RuleCreationValidConsumer, + RuleUiAction, } from '../../../types'; import { getTimeOptions } from '../../../common/lib/get_time_options'; import { ActionForm } from '../action_connector_form'; @@ -360,7 +361,7 @@ export const RuleForm = ({ ); const setActions = useCallback( - (updatedActions: RuleAction[]) => setRuleProperty('actions', updatedActions), + (updatedActions: RuleUiAction[]) => setRuleProperty('actions', updatedActions), [setRuleProperty] ); @@ -372,9 +373,9 @@ export const RuleForm = ({ dispatch({ command: { type: 'setScheduleProperty' }, payload: { key, value } }); }; - const setActionProperty = ( + const setActionProperty = ( key: Key, - value: RuleAction[Key] | null, + value: RuleActionParam | null, index: number ) => { dispatch({ command: { type: 'setRuleActionProperty' }, payload: { key, value, index } }); @@ -632,7 +633,7 @@ export const RuleForm = ({ } // No help text if there is an error - if (errors['schedule.interval'].length > 0) { + if (errors['schedule.interval'].length) { return ''; } @@ -910,7 +911,7 @@ export const RuleForm = ({ rule.ruleTypeId && selectedRuleType ? ( <> - {errors.actionConnectors.length >= 1 ? ( + {!!errors.actionConnectors.length ? ( <> @@ -982,13 +983,13 @@ export const RuleForm = ({ defaultMessage="Name" /> } - isInvalid={errors.name.length > 0 && rule.name !== undefined} + isInvalid={!!errors.name.length && rule.name !== undefined} error={errors.name} > 0 && rule.name !== undefined} + isInvalid={!!errors.name.length && rule.name !== undefined} name="name" data-test-subj="ruleNameInput" value={rule.name || ''} @@ -1118,7 +1119,7 @@ export const RuleForm = ({ - {errors.ruleTypeId.length >= 1 && rule.ruleTypeId !== undefined ? ( + {!!errors.ruleTypeId.length && rule.ruleTypeId !== undefined ? ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.test.ts index 6eadf1fce5ff4..373cb70e0ca36 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.test.ts @@ -5,10 +5,20 @@ * 2.0. */ -import { ruleReducer } from './rule_reducer'; -import { Rule } from '../../../types'; +import { getRuleReducer } from './rule_reducer'; +import { ActionTypeModel, Rule } from '../../../types'; +import { SanitizedRuleAction } from '@kbn/alerting-plugin/common'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +const actionTypeRegistry = actionTypeRegistryMock.create(); +const actionType = { + id: 'test', + name: 'Test', + isSystemActionType: false, +} as unknown as ActionTypeModel; +actionTypeRegistry.get.mockReturnValue(actionType); describe('rule reducer', () => { + const ruleReducer = getRuleReducer(actionTypeRegistry); let initialRule: Rule; beforeAll(() => { initialRule = { @@ -190,7 +200,7 @@ describe('rule reducer', () => { }, } ); - expect(updatedRule.rule.actions[0].group).toBe('Warning'); + expect((updatedRule.rule.actions[0] as SanitizedRuleAction).group).toBe('Warning'); }); test('if rule action frequency was updated', () => { @@ -212,7 +222,9 @@ describe('rule reducer', () => { }, } ); - expect(updatedRule.rule.actions[0].frequency?.notifyWhen).toBe('onThrottleInterval'); + expect((updatedRule.rule.actions[0] as SanitizedRuleAction).frequency?.notifyWhen).toBe( + 'onThrottleInterval' + ); }); test('if initial alert delay property was updated', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts index 257df764ebc1e..c11dd0edb3e71 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_reducer.ts @@ -12,10 +12,12 @@ import { RuleActionParam, IntervalSchedule, RuleActionAlertsFilterProperty, + AlertsFilter, AlertDelay, + SanitizedRuleAction, } from '@kbn/alerting-plugin/common'; import { isEmpty } from 'lodash/fp'; -import { Rule, RuleAction } from '../../../types'; +import { ActionTypeRegistryContract, Rule, RuleUiAction } from '../../../types'; import { DEFAULT_FREQUENCY } from '../../../common/constants'; export type InitialRule = Partial & @@ -52,9 +54,9 @@ interface RulePayload { index?: number; } -interface RuleActionPayload { +interface RuleActionPayload { key: Key; - value: RuleAction[Key] | null; + value: RuleUiAction[Key] | null; index?: number; } @@ -111,204 +113,207 @@ export type RuleReducerAction = export type InitialRuleReducer = Reducer<{ rule: InitialRule }, RuleReducerAction>; export type ConcreteRuleReducer = Reducer<{ rule: Rule }, RuleReducerAction>; -export const ruleReducer = ( - state: { rule: RulePhase }, - action: RuleReducerAction -) => { - const { rule } = state; +export const getRuleReducer = + (actionTypeRegistry: ActionTypeRegistryContract) => + (state: { rule: RulePhase }, action: RuleReducerAction) => { + const { rule } = state; - switch (action.command.type) { - case 'setRule': { - const { key, value } = action.payload as Payload<'rule', RulePhase>; - if (key === 'rule') { - return { - ...state, - rule: value, - }; - } else { - return state; - } - } - case 'setProperty': { - const { key, value } = action.payload as RulePayload; - if (isEqual(rule[key], value)) { - return state; - } else { - return { - ...state, - rule: { - ...rule, - [key]: value, - }, - }; + switch (action.command.type) { + case 'setRule': { + const { key, value } = action.payload as Payload<'rule', RulePhase>; + if (key === 'rule') { + return { + ...state, + rule: value, + }; + } else { + return state; + } } - } - case 'setScheduleProperty': { - const { key, value } = action.payload as RuleSchedulePayload; - if (rule.schedule && isEqual(rule.schedule[key], value)) { - return state; - } else { - return { - ...state, - rule: { - ...rule, - schedule: { - ...rule.schedule, + case 'setProperty': { + const { key, value } = action.payload as RulePayload; + if (isEqual(rule[key], value)) { + return state; + } else { + return { + ...state, + rule: { + ...rule, [key]: value, }, - }, - }; + }; + } } - } - case 'setRuleParams': { - const { key, value } = action.payload as Payload>; - if (isEqual(rule.params[key], value)) { - return state; - } else { - return { - ...state, - rule: { - ...rule, - params: { - ...rule.params, - [key]: value, + case 'setScheduleProperty': { + const { key, value } = action.payload as RuleSchedulePayload; + if (rule.schedule && isEqual(rule.schedule[key], value)) { + return state; + } else { + return { + ...state, + rule: { + ...rule, + schedule: { + ...rule.schedule, + [key]: value, + }, }, - }, - }; + }; + } } - } - case 'setRuleActionParams': { - const { key, value, index } = action.payload as Payload< - keyof RuleAction, - SavedObjectAttribute - >; - if ( - index === undefined || - rule.actions[index] == null || - (!!rule.actions[index][key] && isEqual(rule.actions[index][key], value)) - ) { - return state; - } else { - const oldAction = rule.actions.splice(index, 1)[0]; - const updatedAction = { - ...oldAction, - params: { - ...oldAction.params, - [key]: value, - }, - }; - rule.actions.splice(index, 0, updatedAction); - return { - ...state, - rule: { - ...rule, - actions: [...rule.actions], - }, - }; + case 'setRuleParams': { + const { key, value } = action.payload as Payload>; + if (isEqual(rule.params[key], value)) { + return state; + } else { + return { + ...state, + rule: { + ...rule, + params: { + ...rule.params, + [key]: value, + }, + }, + }; + } } - } - case 'setRuleActionFrequency': { - const { key, value, index } = action.payload as Payload< - keyof RuleAction, - SavedObjectAttribute - >; - if ( - index === undefined || - rule.actions[index] == null || - (!!rule.actions[index][key] && isEqual(rule.actions[index][key], value)) - ) { - return state; - } else { - const oldAction = rule.actions.splice(index, 1)[0]; - const updatedAction = { - ...oldAction, - frequency: { - ...(oldAction.frequency ?? DEFAULT_FREQUENCY), - [key]: value, - }, - }; - rule.actions.splice(index, 0, updatedAction); - return { - ...state, - rule: { - ...rule, - actions: [...rule.actions], - }, - }; + case 'setRuleActionParams': { + const { key, value, index } = action.payload as Payload< + keyof RuleUiAction, + SavedObjectAttribute + >; + if ( + index === undefined || + rule.actions[index] == null || + (!!rule.actions[index][key] && isEqual(rule.actions[index][key], value)) + ) { + return state; + } else { + const oldAction = rule.actions.splice(index, 1)[0]; + const updatedAction = { + ...oldAction, + params: { + ...oldAction.params, + [key]: value, + }, + }; + rule.actions.splice(index, 0, updatedAction); + return { + ...state, + rule: { + ...rule, + actions: [...rule.actions], + }, + }; + } } - } - case 'setRuleActionAlertsFilter': { - const { key, value, index } = action.payload as Payload< - keyof RuleAction, - SavedObjectAttribute - >; - if ( - index === undefined || - rule.actions[index] == null || - (!!rule.actions[index][key] && isEqual(rule.actions[index][key], value)) - ) { - return state; - } else { - const oldAction = rule.actions.splice(index, 1)[0]; - const { alertsFilter, ...rest } = oldAction; - const updatedAlertsFilter = { ...alertsFilter }; - - if (value) { - updatedAlertsFilter[key] = value; + case 'setRuleActionFrequency': { + const { key, value, index } = action.payload as Payload< + keyof RuleUiAction, + SavedObjectAttribute + >; + if ( + index === undefined || + rule.actions[index] == null || + (!!rule.actions[index][key] && isEqual(rule.actions[index][key], value)) + ) { + return state; } else { - delete updatedAlertsFilter[key]; + const oldAction = rule.actions.splice(index, 1)[0]; + if (actionTypeRegistry.get(oldAction.actionTypeId).isSystemActionType) { + return state; + } + const oldSanitizedAction = oldAction as SanitizedRuleAction; + const updatedAction = { + ...oldSanitizedAction, + frequency: { + ...(oldSanitizedAction?.frequency ?? DEFAULT_FREQUENCY), + [key]: value, + }, + }; + rule.actions.splice(index, 0, updatedAction); + return { + ...state, + rule: { + ...rule, + actions: [...rule.actions], + }, + }; } + } + case 'setRuleActionAlertsFilter': { + const { key, value, index } = action.payload as Payload< + keyof AlertsFilter, + RuleActionAlertsFilterProperty + >; + if (index === undefined || rule.actions[index] == null) { + return state; + } else { + const oldAction = rule.actions.splice(index, 1)[0]; + if (actionTypeRegistry.get(oldAction.actionTypeId).isSystemActionType) { + return state; + } + const oldSanitizedAction = oldAction as SanitizedRuleAction; + if ( + oldSanitizedAction.alertsFilter && + isEqual(oldSanitizedAction.alertsFilter[key], value) + ) + return state; + + const { alertsFilter, ...rest } = oldSanitizedAction; + const updatedAlertsFilter = { ...alertsFilter, [key]: value }; - const updatedAction = { - ...rest, - ...(!isEmpty(updatedAlertsFilter) ? { alertsFilter: updatedAlertsFilter } : {}), - }; - rule.actions.splice(index, 0, updatedAction); - return { - ...state, - rule: { - ...rule, - actions: [...rule.actions], - }, - }; + const updatedAction = { + ...rest, + ...(!isEmpty(updatedAlertsFilter) ? { alertsFilter: updatedAlertsFilter } : {}), + }; + rule.actions.splice(index, 0, updatedAction); + return { + ...state, + rule: { + ...rule, + actions: [...rule.actions], + }, + }; + } } - } - case 'setRuleActionProperty': { - const { key, value, index } = action.payload as RuleActionPayload; - if (index === undefined || isEqual(rule.actions[index][key], value)) { - return state; - } else { - const oldAction = rule.actions.splice(index, 1)[0]; - const updatedAction = { - ...oldAction, - [key]: value, - }; - rule.actions.splice(index, 0, updatedAction); - return { - ...state, - rule: { - ...rule, - actions: [...rule.actions], - }, - }; + case 'setRuleActionProperty': { + const { key, value, index } = action.payload as RuleActionPayload; + if (index === undefined || isEqual(rule.actions[index][key], value)) { + return state; + } else { + const oldAction = rule.actions.splice(index, 1)[0]; + const updatedAction = { + ...oldAction, + [key]: value, + }; + rule.actions.splice(index, 0, updatedAction); + return { + ...state, + rule: { + ...rule, + actions: [...rule.actions], + }, + }; + } } - } - case 'setAlertDelayProperty': { - const { key, value } = action.payload as Payload; - if (rule.alertDelay && isEqual(rule.alertDelay[key], value)) { - return state; - } else { - return { - ...state, - rule: { - ...rule, - alertDelay: { - ...rule.alertDelay, - [key]: value, + case 'setAlertDelayProperty': { + const { key, value } = action.payload as Payload; + if (rule.alertDelay && isEqual(rule.alertDelay[key], value)) { + return state; + } else { + return { + ...state, + rule: { + ...rule, + alertDelay: { + ...rule.alertDelay, + [key]: value, + }, }, - }, - }; + }; + } } } - } -}; + }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx index 9bcab7c092421..a8c07077ed389 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/collapsed_item_actions.test.tsx @@ -62,7 +62,12 @@ describe('CollapsedItemActions', () => { consumer: 'rules', schedule: { interval: '5d' }, actions: [ - { id: 'test', actionTypeId: 'the_connector', group: 'rule', params: { message: 'test' } }, + { + id: 'test', + actionTypeId: 'the_connector', + group: 'rule', + params: { message: 'test' }, + }, ], params: { name: 'test rule type name' }, createdBy: null, diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index fbbbf04ea8cfe..a23388e24abd8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -11,8 +11,8 @@ import type { PluginInitializerContext } from '@kbn/core/server'; import { Plugin } from './plugin'; export type { - RuleAction, Rule, + RuleAction, RuleType, RuleTypeIndex, RuleTypeModel, diff --git a/x-pack/plugins/triggers_actions_ui/public/mocks.ts b/x-pack/plugins/triggers_actions_ui/public/mocks.ts index 48691c15ed62f..1c09a3895f9fd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/mocks.ts +++ b/x-pack/plugins/triggers_actions_ui/public/mocks.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { RuleAction } from '@kbn/alerting-plugin/common'; import { getAlertsTableDefaultAlertActionsLazy } from './common/get_alerts_table_default_row_actions'; import type { TriggersAndActionsUIPublicPluginStart } from './plugin'; @@ -22,6 +23,7 @@ import { RuleTagBadgeProps, RuleEventLogListOptions, RuleEventLogListProps, + RuleUiAction, } from './types'; import { getAlertsTableLazy } from './common/get_alerts_table'; import { getRuleStatusDropdownLazy } from './common/get_rule_status_dropdown'; @@ -58,8 +60,18 @@ function createStartMock(): TriggersAndActionsUIPublicPluginStart { actionTypeRegistry, ruleTypeRegistry, alertsTableConfigurationRegistry, - getActionForm: (props: Omit) => { - return getActionFormLazy({ ...props, actionTypeRegistry, connectorServices }); + getActionForm: ( + props: Omit & { + setActions: (actions: RuleAction[]) => void; + } + ) => { + const { setActions, ...restProps } = props; + return getActionFormLazy({ + ...restProps, + setActions: setActions as (actions: RuleUiAction[]) => void, + actionTypeRegistry, + connectorServices, + }); }, getAddConnectorFlyout: (props: Omit) => { return getAddConnectorFlyoutLazy({ ...props, actionTypeRegistry, connectorServices }); diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index a8c4dd1f71646..174d3816792bf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -30,8 +30,9 @@ import { ExpressionsStart } from '@kbn/expressions-plugin/public'; import { ServerlessPluginStart } from '@kbn/serverless/public'; import { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; import { LensPublicStart } from '@kbn/lens-plugin/public'; +import { RuleAction } from '@kbn/alerting-plugin/common'; import { getAlertsTableDefaultAlertActionsLazy } from './common/get_alerts_table_default_row_actions'; -import type { AlertActionsProps } from './types'; +import type { AlertActionsProps, RuleUiAction } from './types'; import type { AlertsSearchBarProps } from './application/sections/alerts_search_bar'; import { TypeRegistry } from './application/type_registry'; @@ -108,7 +109,9 @@ export interface TriggersAndActionsUIPublicPluginStart { ruleTypeRegistry: TypeRegistry>; alertsTableConfigurationRegistry: AlertTableConfigRegistry; getActionForm: ( - props: Omit + props: Omit & { + setActions: (actions: RuleAction[]) => void; + } ) => ReactElement; getAddConnectorFlyout: ( props: Omit @@ -457,9 +460,16 @@ export class Plugin actionTypeRegistry: this.actionTypeRegistry, ruleTypeRegistry: this.ruleTypeRegistry, alertsTableConfigurationRegistry: this.alertsTableConfigurationRegistry, - getActionForm: (props: Omit) => { + getActionForm: ( + props: Omit & { + setActions: (actions: RuleAction[]) => void; + } + ) => { + const { setActions, ...restProps } = props; return getActionFormLazy({ - ...props, + ...restProps, + // TODO remove this cast when every solution is ready to use system actions + setActions: setActions as (actions: RuleUiAction[]) => void, actionTypeRegistry: this.actionTypeRegistry, connectorServices: this.connectorServices!, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 0bbcc5e90c7a9..7533746c87236 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -42,7 +42,7 @@ import { RuleActionParam, SanitizedRule as AlertingSanitizedRule, ResolvedSanitizedRule, - RuleAction, + RuleSystemAction, RuleTaskState, AlertSummary as RuleSummary, ExecutionDuration, @@ -55,6 +55,7 @@ import { ActionVariable, RuleLastRun, MaintenanceWindow, + SanitizedRuleAction as RuleAction, } from '@kbn/alerting-plugin/common'; import type { BulkOperationError } from '@kbn/alerting-plugin/server'; import { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common'; @@ -104,22 +105,31 @@ export { OPTIONAL_ACTION_VARIABLES, } from '@kbn/triggers-actions-ui-types'; +type RuleUiAction = RuleAction | RuleSystemAction; + // In Triggers and Actions we treat all `Alert`s as `SanitizedRule` // so the `Params` is a black-box of Record type SanitizedRule = Omit< AlertingSanitizedRule, - 'alertTypeId' + 'alertTypeId' | 'actions' | 'systemActions' > & { ruleTypeId: AlertingSanitizedRule['alertTypeId']; + actions: RuleUiAction[]; }; type Rule = SanitizedRule; -type ResolvedRule = Omit, 'alertTypeId'> & { +type ResolvedRule = Omit< + ResolvedSanitizedRule, + 'alertTypeId' | 'actions' | 'systemActions' +> & { ruleTypeId: ResolvedSanitizedRule['alertTypeId']; + actions: RuleUiAction[]; }; export type { Rule, RuleAction, + RuleSystemAction, + RuleUiAction, RuleTaskState, RuleSummary, ExecutionDuration, @@ -287,6 +297,7 @@ export interface ActionTypeModel ActionParams | {}; hideInUi?: boolean; modalWidth?: number; + isSystemActionType?: boolean; } export interface GenericValidationResult { diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index cefba18db5b9b..30a8b86b1e630 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -72,6 +72,7 @@ const enabledActionTypes = [ 'test.capped', 'test.system-action', 'test.system-action-kibana-privileges', + 'test.system-action-connector-adapter', ]; export function createTestConfig(name: string, options: CreateTestConfigOptions) { diff --git a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts index 6cf75ee6ad635..90653108aa500 100644 --- a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts @@ -354,6 +354,36 @@ export class AlertUtils { return response; } + public async createAlwaysFiringSystemAction({ + objectRemover, + overwrites = {}, + reference, + }: CreateAlertWithActionOpts) { + const objRemover = objectRemover || this.objectRemover; + + if (!objRemover) { + throw new Error('objectRemover is required'); + } + + let request = this.supertestWithoutAuth + .post(`${getUrlPrefix(this.space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo'); + + if (this.user) { + request = request.auth(this.user.username, this.user.password); + } + + const rule = getAlwaysFiringRuleWithSystemAction(reference); + + const response = await request.send({ ...rule, ...overwrites }); + + if (response.statusCode === 200) { + objRemover.add(this.space.id, response.body.id, 'rule', 'alerting'); + } + + return response; + } + public async updateAlwaysFiringAction({ alertId, actionId, @@ -658,3 +688,32 @@ function getPatternFiringRuleWithSummaryAction( ], }; } + +function getAlwaysFiringRuleWithSystemAction(reference: string) { + return { + enabled: true, + name: 'abc', + schedule: { interval: '1m' }, + tags: ['tag-A', 'tag-B'], + rule_type_id: 'test.always-firing-alert-as-data', + consumer: 'alertsFixture', + params: { + index: ES_TEST_INDEX_NAME, + reference, + }, + actions: [ + { + id: 'system-connector-test.system-action-connector-adapter', + /** + * The injected param required by the action will be set by the corresponding + * connector adapter. Setting it here it will lead to a 400 error by the + * rules API as only the connector adapter can set the injected property. + * + * Adapter: x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts + * Connector type: x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts + */ + params: { myParam: 'param from rule action', index: ES_TEST_INDEX_NAME, reference }, + }, + ], + }; +} diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts index 37d66450962b4..27df735ad73dc 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts @@ -82,6 +82,7 @@ export function defineActionTypes( */ actions.registerType(getSystemActionType()); actions.registerType(getSystemActionTypeWithKibanaPrivileges()); + actions.registerType(getSystemActionTypeWithConnectorAdapter()); /** Sub action framework */ @@ -486,3 +487,67 @@ function getSystemActionTypeWithKibanaPrivileges() { return result; } + +function getSystemActionTypeWithConnectorAdapter() { + const result: ActionType< + {}, + {}, + { myParam: string; injected: string; index?: string; reference?: string } + > = { + id: 'test.system-action-connector-adapter', + name: 'Test system action with a connector adapter set', + minimumLicenseRequired: 'platinum', + supportedFeatureIds: ['alerting'], + validate: { + params: { + /** + * The injected params will be set by the + * connector adapter while executing the action. + * + * Adapter: x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts + */ + schema: schema.object({ + myParam: schema.string(), + injected: schema.string(), + index: schema.maybe(schema.string()), + reference: schema.maybe(schema.string()), + }), + }, + + config: { + schema: schema.any(), + }, + secrets: { + schema: schema.any(), + }, + }, + isSystemActionType: true, + /** + * The executor writes a doc to the + * testing index. The test uses the doc + * to verify that the action is executed + * correctly + */ + async executor({ params, services, actionId }) { + const { index, reference } = params; + + if (index == null || reference == null) { + return { status: 'ok', actionId }; + } + + await services.scopedClusterClient.index({ + index, + refresh: 'wait_for', + body: { + params, + reference, + source: 'action:test.system-action-connector-adapter', + }, + }); + + return { status: 'ok', actionId }; + }, + }; + + return result; +} diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts new file mode 100644 index 0000000000000..41526e0949de3 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConnectorAdapter } from '@kbn/alerting-plugin/server'; +import { CoreSetup } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; +import { FixtureStartDeps, FixtureSetupDeps } from './plugin'; + +export function defineConnectorAdapters( + core: CoreSetup, + { alerting }: Pick +) { + const systemActionConnectorAdapter: ConnectorAdapter = { + connectorTypeId: 'test.system-action-connector-adapter', + ruleActionParamsSchema: schema.object({ + myParam: schema.string(), + index: schema.maybe(schema.string()), + reference: schema.maybe(schema.string()), + }), + /** + * The connector adapter will inject a new param property which is required + * by the action. The injected value cannot be set in the actions of the rule + * as the schema validation will thrown an error. Only through the connector + * adapter this value can be set. The tests are using the connector adapter test + * that the new property is injected correctly + */ + buildActionParams: ({ alerts, rule, params, spaceId, ruleUrl }) => { + return { ...params, injected: 'param from connector adapter' }; + }, + }; + + alerting.registerConnectorAdapter(systemActionConnectorAdapter); +} diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts index b315c2f884761..dffb9df550f3a 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/plugin.ts @@ -28,6 +28,7 @@ import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; import { defineRoutes } from './routes'; import { defineActionTypes } from './action_types'; import { defineAlertTypes } from './alert_types'; +import { defineConnectorAdapters } from './connector_adapters'; export interface FixtureSetupDeps { features: FeaturesPluginSetup; @@ -163,6 +164,7 @@ export class FixturePlugin implements Plugin, + res: KibanaResponseFactory + ): Promise> => { + const [_, { actions }] = await core.getStartServices(); + + const actionsClient = await actions.getActionsClientWithRequest(req); + + try { + return res.ok({ + body: await actionsClient.execute({ + actionId: req.params.id, + params: req.body.params, + source: { + type: ActionExecutionSourceType.HTTP_REQUEST, + source: req, + }, + relatedSavedObjects: [], + }), + }); + } catch (err) { + if (err.isBoom && err.output.statusCode === 403) { + return res.forbidden({ body: err }); + } + + throw err; + } + } + ); } diff --git a/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts b/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts index 3e836987939b3..dcc227761d581 100644 --- a/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts +++ b/x-pack/test/alerting_api_integration/observability/synthetics_rule.ts @@ -122,8 +122,10 @@ const compareRules = (rule1: SanitizedRule, rule2: SanitizedRule) => { const getActionById = (rule: SanitizedRule, id: string) => { const actions = rule.actions.filter((action) => action.id === id); - const recoveredAction = actions.find((action) => action.group === 'recovered'); - const firingAction = actions.find((action) => action.group !== 'recovered'); + const recoveredAction = actions.find( + (action) => 'group' in action && action.group === 'recovered' + ); + const firingAction = actions.find((action) => 'group' in action && action.group !== 'recovered'); return { recoveredAction: omit(recoveredAction, ['uuid']), firingAction: omit(firingAction, ['uuid']), diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts index 295350e6dc18e..22c2f9ce6489e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/find.ts @@ -9,7 +9,7 @@ import expect from '@kbn/expect'; import { SuperTest, Test } from 'supertest'; import { chunk, omit } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import { UserAtSpaceScenarios } from '../../../scenarios'; +import { SuperuserAtSpace1, UserAtSpaceScenarios } from '../../../scenarios'; import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -573,6 +573,71 @@ const findTestUtils = ( }); } }); + + describe('Actions', () => { + const { user, space } = SuperuserAtSpace1; + + it('should return the actions correctly', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule1 } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: true, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }) + ) + .expect(200); + + objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerting/rules/_find`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + const action = response.body.data[0].actions[0]; + const systemAction = response.body.data[0].actions[1]; + const { uuid, ...restAction } = action; + const { uuid: systemActionUuid, ...restSystemAction } = systemAction; + + expect([restAction, restSystemAction]).to.eql([ + { + id: createdAction.id, + connector_type_id: 'test.noop', + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + }, + , + ]); + }); + }); }; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts index edacff0fbfd13..6d245ed28cc00 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/get.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import { SuperTest, Test } from 'supertest'; -import { UserAtSpaceScenarios } from '../../../scenarios'; +import { SuperuserAtSpace1, UserAtSpaceScenarios } from '../../../scenarios'; import { getUrlPrefix, getTestRuleData, @@ -315,6 +315,71 @@ const getTestUtils = ( }); } }); + + describe('Actions', () => { + const { user, space } = SuperuserAtSpace1; + + it('should return the actions correctly', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: true, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }) + ) + .expect(200); + + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + const action = response.body.actions[0]; + const systemAction = response.body.actions[1]; + const { uuid, ...restAction } = action; + const { uuid: systemActionUuid, ...restSystemAction } = systemAction; + + expect([restAction, restSystemAction]).to.eql([ + { + id: createdAction.id, + connector_type_id: 'test.noop', + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + }, + , + ]); + }); + }); }; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types_system.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types_system.ts new file mode 100644 index 0000000000000..f9ea6cf44ddba --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types_system.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { UserAtSpaceScenarios } from '../../../scenarios'; +import { getUrlPrefix } from '../../../../common/lib/space_test_utils'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function listActionTypesTests({ getService }: FtrProviderContext) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('connector_types', () => { + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should return 200 with list of action types containing defaults', async () => { + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/actions/connector_types`) + .auth(user.username, user.password); + + function createActionTypeMatcher(id: string, name: string) { + return (actionType: { id: string; name: string }) => { + return actionType.id === id && actionType.name === name; + }; + } + + expect(response.statusCode).to.eql(200); + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + // Check for values explicitly in order to avoid this test failing each time plugins register + // a new action type + expect( + response.body.some( + createActionTypeMatcher('test.index-record', 'Test: Index Record') + ) + ).to.be(true); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should return 200 with list of action types containing system connectors', async () => { + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/actions/connector_types`) + .auth(user.username, user.password); + + function createActionTypeMatcher(id: string, name: string) { + return (actionType: { id: string; name: string }) => { + return actionType.id === id && actionType.name === name; + }; + } + + expect(response.statusCode).to.eql(200); + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + // Check for values explicitly in order to avoid this test failing each time plugins register + // a new action type + expect( + response.body.some( + createActionTypeMatcher('test.system-action', 'Test system action') + ) + ).to.be(true); + + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts index d12bc59aca4df..448296a4ae00c 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts @@ -511,8 +511,14 @@ export default function ({ getService }: FtrProviderContext) { const name = 'System action: test.system-action-kibana-privileges'; const reference = `actions-enqueue-${scenario.id}:${space.id}:${connectorId}`; + /** + * The test are using a test endpoint that calls the actions client. + * The route is defined here x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts. + * The public execute API does not allows the execution of system actions. We use the + * test route to test the execution of system actions + */ const response = await supertestWithoutAuth - .post(`${getUrlPrefix(space.id)}/api/actions/connector/${connectorId}/_execute`) + .post(`${getUrlPrefix(space.id)}/api/alerts_fixture/${connectorId}/_execute_connector`) .auth(user.username, user.password) .set('kbn-xsrf', 'foo') .send({ @@ -536,7 +542,7 @@ export default function ({ getService }: FtrProviderContext) { expect(response.body).to.eql({ statusCode: 403, error: 'Forbidden', - message: 'Unauthorized to execute actions', + message: 'Unauthorized to execute a "test.system-action-kibana-privileges" action', }); break; /** diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts new file mode 100644 index 0000000000000..2811c7e2d4ce2 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts @@ -0,0 +1,590 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; +import { SuperuserAtSpace1, UserAtSpaceScenarios } from '../../../scenarios'; +import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function getAllActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('getAllSystem', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle get all action request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + connector_type_id: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/actions/connectors`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to get actions', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + + // the custom ssl connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomSslConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') + ); + expect(nonCustomSslConnectors).to.eql([ + { + id: createdAction.id, + is_preconfigured: false, + is_system_action: false, + is_deprecated: false, + name: 'My action', + connector_type_id: 'test.index-record', + is_missing_secrets: false, + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + referenced_by_count: 0, + }, + { + connector_type_id: '.email', + id: 'notification-email', + is_deprecated: false, + is_system_action: false, + is_preconfigured: true, + name: 'Notification Email Connector', + referenced_by_count: 0, + }, + { + id: 'preconfigured-es-index-action', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: '.index', + name: 'preconfigured_es_index_action', + referenced_by_count: 0, + }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow', + is_preconfigured: true, + is_system_action: false, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_system_action: false, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, + { + id: 'my-slack1', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: '.slack', + name: 'Slack#xyz', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action', + id: 'system-connector-test.system-action', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action-connector-adapter', + id: 'system-connector-test.system-action-connector-adapter', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action-connector-adapter', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action-kibana-privileges', + id: 'system-connector-test.system-action-kibana-privileges', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action-kibana-privileges', + referenced_by_count: 0, + }, + { + id: 'custom-system-abc-connector', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: 'system-abc-action-type', + name: 'SystemABC', + referenced_by_count: 0, + }, + { + id: 'preconfigured.test.index-record', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: 'test.index-record', + name: 'Test:_Preconfigured_Index_Record', + referenced_by_count: 0, + }, + { + id: 'my-test-email', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: '.email', + name: 'TestEmail#xyz', + referenced_by_count: 0, + }, + ]); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle get all request appropriately with proper referenced_by_count', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + connector_type_id: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [ + { + group: 'default', + id: createdAction.id, + params: {}, + }, + { + group: 'default', + id: 'my-slack1', + params: { + message: 'test', + }, + }, + ], + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, RULE_SAVED_OBJECT_TYPE, 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/actions/connectors`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to get actions', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + + // the custom ssl connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomSslConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') + ); + expect(nonCustomSslConnectors).to.eql([ + { + id: createdAction.id, + is_preconfigured: false, + is_system_action: false, + is_deprecated: false, + name: 'My action', + connector_type_id: 'test.index-record', + is_missing_secrets: false, + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + referenced_by_count: 1, + }, + { + connector_type_id: '.email', + id: 'notification-email', + is_deprecated: false, + is_preconfigured: true, + is_system_action: false, + name: 'Notification Email Connector', + referenced_by_count: 0, + }, + { + id: 'preconfigured-es-index-action', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: '.index', + name: 'preconfigured_es_index_action', + referenced_by_count: 0, + }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow', + is_deprecated: true, + is_preconfigured: true, + is_system_action: false, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_system_action: false, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, + { + id: 'my-slack1', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: '.slack', + name: 'Slack#xyz', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action', + id: 'system-connector-test.system-action', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action-connector-adapter', + id: 'system-connector-test.system-action-connector-adapter', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action-connector-adapter', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action-kibana-privileges', + id: 'system-connector-test.system-action-kibana-privileges', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action-kibana-privileges', + referenced_by_count: 0, + }, + { + id: 'custom-system-abc-connector', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: 'system-abc-action-type', + name: 'SystemABC', + referenced_by_count: 0, + }, + { + id: 'preconfigured.test.index-record', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: 'test.index-record', + name: 'Test:_Preconfigured_Index_Record', + referenced_by_count: 0, + }, + { + id: 'my-test-email', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: '.email', + name: 'TestEmail#xyz', + referenced_by_count: 0, + }, + ]); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`shouldn't get actions from another space`, async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + connector_type_id: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix('other')}/internal/actions/connectors`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all at space2': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to get actions', + }); + break; + case 'global_read at space1': + case 'superuser at space1': + expect(response.statusCode).to.eql(200); + + // the custom ssl connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomSslConnectors = response.body.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') + ); + expect(nonCustomSslConnectors).to.eql([ + { + connector_type_id: '.email', + id: 'notification-email', + is_deprecated: false, + is_preconfigured: true, + is_system_action: false, + name: 'Notification Email Connector', + referenced_by_count: 0, + }, + { + id: 'preconfigured-es-index-action', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: '.index', + name: 'preconfigured_es_index_action', + referenced_by_count: 0, + }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow', + is_preconfigured: true, + is_system_action: false, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_system_action: false, + is_deprecated: true, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, + { + id: 'my-slack1', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: '.slack', + name: 'Slack#xyz', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action', + id: 'system-connector-test.system-action', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action-connector-adapter', + id: 'system-connector-test.system-action-connector-adapter', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action-connector-adapter', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action-kibana-privileges', + id: 'system-connector-test.system-action-kibana-privileges', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action-kibana-privileges', + referenced_by_count: 0, + }, + { + id: 'custom-system-abc-connector', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: 'system-abc-action-type', + name: 'SystemABC', + referenced_by_count: 0, + }, + { + id: 'preconfigured.test.index-record', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: 'test.index-record', + name: 'Test:_Preconfigured_Index_Record', + referenced_by_count: 0, + }, + { + id: 'my-test-email', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: '.email', + name: 'TestEmail#xyz', + referenced_by_count: 0, + }, + ]); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + + describe('References', () => { + const systemAction = { + id: 'system-connector-test.system-action', + params: {}, + }; + + it('calculates the references correctly', async () => { + const { user, space } = SuperuserAtSpace1; + + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + name: 'My action', + connector_type_id: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + + objectRemover.add(space.id, createdAction.id, 'action', 'actions'); + + const ruleRes = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [ + systemAction, + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + + objectRemover.add(space.id, ruleRes.body.id, 'rule', 'alerting'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/actions/connectors`) + .auth(user.username, user.password) + .expect(200); + + const connectors = response.body as Array<{ id: string; referenced_by_count: number }>; + + const createdConnector = connectors.find((connector) => connector.id === createdAction.id); + const systemConnector = connectors.find((connector) => connector.id === systemAction.id); + + expect(createdConnector?.referenced_by_count).to.be(1); + expect(systemConnector?.referenced_by_count).to.be(0); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index dc1876d6e784b..0ccc0b5770ea2 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -47,8 +47,10 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./execute')); loadTestFile(require.resolve('./get_all')); + loadTestFile(require.resolve('./get_all_system')); loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./connector_types')); + loadTestFile(require.resolve('./connector_types_system')); loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./bulk_enqueue')); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts index b4a775b7a6635..3771039e392c9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts @@ -7,8 +7,11 @@ import expect from '@kbn/expect'; import { Response as SupertestResponse } from 'supertest'; -import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; -import { UserAtSpaceScenarios } from '../../../scenarios'; +import { RuleNotifyWhen, RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; +import { RawRule } from '@kbn/alerting-plugin/server/types'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { SavedObject } from '@kbn/core-saved-objects-api-server'; +import { SuperuserAtSpace1, UserAtSpaceScenarios } from '../../../scenarios'; import { checkAAD, getUrlPrefix, @@ -24,6 +27,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const supertestWithoutAuth = getService('supertestWithoutAuth'); const retry = getService('retry'); + const es = getService('es'); function getAlertingTaskById(taskId: string) { return supertest @@ -992,5 +996,287 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { }); }); } + + describe('Actions', () => { + const { user, space } = SuperuserAtSpace1; + + it('should update a rule with actions correctly', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + const updateResponse = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + name: 'bcd', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + throttle: '1m', + notify_when: 'onThrottleInterval', + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }) + .expect(200); + + objectRemover.add(space.id, updateResponse.body.id, 'rule', 'alerting'); + + const action = updateResponse.body.actions[0]; + const systemAction = updateResponse.body.actions[1]; + const { uuid, ...restAction } = action; + const { uuid: systemActionUuid, ...restSystemAction } = systemAction; + + expect([restAction, restSystemAction]).to.eql([ + { + id: createdAction.id, + connector_type_id: 'test.noop', + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + }, + , + ]); + + const esResponse = await es.get>( + { + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `alert:${updateResponse.body.id}`, + }, + { meta: true } + ); + + expect(esResponse.statusCode).to.eql(200); + expect((esResponse.body._source as any)?.alert.systemActions).to.be(undefined); + const rawActions = (esResponse.body._source as any)?.alert.actions ?? []; + + const rawAction = rawActions[0]; + const rawSystemAction = rawActions[1]; + + const { uuid: rawActionUuid, ...rawActionRest } = rawAction; + const { uuid: rawSystemActionUuid, ...rawSystemActionRest } = rawSystemAction; + + expect(rawActionRest).to.eql({ + actionRef: 'action_0', + actionTypeId: 'test.noop', + params: {}, + group: 'default', + }); + + expect(rawSystemActionRest).to.eql({ + actionRef: 'system_action:system-connector-test.system-action', + actionTypeId: 'test.system-action', + params: {}, + }); + + expect(rawActionUuid).to.not.be(undefined); + expect(rawSystemActionUuid).to.not.be(undefined); + }); + + it('should throw 400 if the system action is missing required params', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + name: 'bcd', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + throttle: '1m', + notify_when: 'onThrottleInterval', + actions: [ + { + params: {}, + }, + ], + }) + .expect(400); + }); + + it('strips out properties from system actions that are part of the default actions', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + for (const propertyToAdd of [ + { group: 'default' }, + { + frequency: { + summary: false, + throttle: '1s', + notify_when: RuleNotifyWhen.THROTTLE, + }, + }, + { + alerts_filter: { + query: { kql: 'kibana.alert.rule.name:abc', filters: [] }, + }, + }, + ]) { + const updateResponse = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + name: 'bcd', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + throttle: '1m', + notify_when: 'onThrottleInterval', + actions: [ + { + id: 'system-connector-test.system-action', + params: {}, + ...propertyToAdd, + }, + ], + }) + .expect(200); + + expect(updateResponse.body.actions[0][Object.keys(propertyToAdd)[0]]).to.be(undefined); + + const esResponse = await es.get>( + { + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `alert:${updateResponse.body.id}`, + }, + { meta: true } + ); + + expect(esResponse.statusCode).to.eql(200); + expect((esResponse.body._source as any)?.alert.systemActions).to.be(undefined); + + const rawActions = (esResponse.body._source as any)?.alert.actions ?? []; + expect(rawActions[0][Object.keys(propertyToAdd)[0]]).to.be(undefined); + } + }); + + it('should throw 400 when using the same system action twice', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + name: 'bcd', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + throttle: '1m', + notify_when: 'onThrottleInterval', + actions: [ + { + id: 'system-connector-test.system-action', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }) + .expect(400); + }); + + it('should not allow creating a default action without group', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + name: 'bcd', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + throttle: '1m', + notify_when: 'onThrottleInterval', + actions: [ + { + // group is missing + id: createdAction.id, + params: {}, + }, + ], + }) + .expect(400); + }); + }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_delete.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_delete.ts index 4d2e9781688c9..fc3526eebc6a4 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_delete.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_delete.ts @@ -674,5 +674,68 @@ export default ({ getService }: FtrProviderContext) => { }); }); }); + + describe('Actions', () => { + const { user, space } = SuperuserAtSpace1; + + it('should return the actions correctly', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule1 } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }) + ) + .expect(200); + + const response = await supertestWithoutAuth + .patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`) + .set('kbn-xsrf', 'foo') + .send({ ids: [createdRule1.id] }) + .auth(user.username, user.password); + + const action = response.body.rules[0].actions[0]; + const systemAction = response.body.rules[0].actions[1]; + const { uuid, ...restAction } = action; + const { uuid: systemActionUuid, ...restSystemAction } = systemAction; + + expect([restAction, restSystemAction]).to.eql([ + { + id: createdAction.id, + connector_type_id: 'test.noop', + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + }, + , + ]); + }); + }); }); }; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_disable.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_disable.ts index bf31e09fa5819..d69055a92e1f4 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_disable.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_disable.ts @@ -541,5 +541,71 @@ export default ({ getService }: FtrProviderContext) => { }); }); }); + + describe('Actions', () => { + const { user, space } = SuperuserAtSpace1; + + it('should return the actions correctly', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule1 } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: true, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }) + ) + .expect(200); + + objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting'); + + const response = await supertestWithoutAuth + .patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_disable`) + .set('kbn-xsrf', 'foo') + .send({ ids: [createdRule1.id] }) + .auth(user.username, user.password); + + const action = response.body.rules[0].actions[0]; + const systemAction = response.body.rules[0].actions[1]; + const { uuid, ...restAction } = action; + const { uuid: systemActionUuid, ...restSystemAction } = systemAction; + + expect([restAction, restSystemAction]).to.eql([ + { + id: createdAction.id, + connector_type_id: 'test.noop', + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + }, + , + ]); + }); + }); }); }; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_edit.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_edit.ts index 8dec0a90e8d31..f09ab4b2fe66d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_edit.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_edit.ts @@ -6,9 +6,12 @@ */ import expect from '@kbn/expect'; -import type { SanitizedRule } from '@kbn/alerting-plugin/common'; +import { SavedObject } from '@kbn/core/server'; +import { RuleNotifyWhen, SanitizedRule } from '@kbn/alerting-plugin/common'; import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; -import { UserAtSpaceScenarios } from '../../../scenarios'; +import { RawRule } from '@kbn/alerting-plugin/server/types'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { SuperuserAtSpace1, UserAtSpaceScenarios } from '../../../scenarios'; import { checkAAD, getUrlPrefix, @@ -24,6 +27,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); describe('bulkEdit', () => { + const es = getService('es'); const objectRemover = new ObjectRemover(supertest); after(() => objectRemover.removeAll()); @@ -601,8 +605,6 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { } describe('do NOT delete reference for rule type like', () => { - const es = getService('es'); - it('.esquery', async () => { const space1 = UserAtSpaceScenarios[1].space.id; const { body: createdRule } = await supertest @@ -677,5 +679,279 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { ); }); }); + + describe('Actions', () => { + const { space } = SuperuserAtSpace1; + + it('should add the actions correctly', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const response = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send({ + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }, + ], + }); + + expect(response.status).to.eql(200); + + const action = response.body.rules[0].actions[0]; + const systemAction = response.body.rules[0].actions[1]; + const { uuid, ...restAction } = action; + const { uuid: systemActionUuid, ...restSystemAction } = systemAction; + + expect([restAction, restSystemAction]).to.eql([ + { + id: createdAction.id, + connector_type_id: 'test.noop', + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + }, + , + ]); + + const esResponse = await es.get>( + { + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `alert:${response.body.rules[0].id}`, + }, + { meta: true } + ); + + expect(esResponse.statusCode).to.eql(200); + expect((esResponse.body._source as any)?.alert.systemActions).to.be(undefined); + const rawActions = (esResponse.body._source as any)?.alert.actions ?? []; + + const rawAction = rawActions[0]; + const rawSystemAction = rawActions[1]; + + const { uuid: rawActionUuid, ...rawActionRest } = rawAction; + const { uuid: rawSystemActionUuid, ...rawSystemActionRest } = rawSystemAction; + + expect(rawActionRest).to.eql({ + actionRef: 'action_0', + actionTypeId: 'test.noop', + params: {}, + group: 'default', + }); + + expect(rawSystemActionRest).to.eql({ + actionRef: 'system_action:system-connector-test.system-action', + actionTypeId: 'test.system-action', + params: {}, + }); + + expect(rawActionUuid).to.not.be(undefined); + expect(rawSystemActionUuid).to.not.be(undefined); + }); + + it('should not allow creating a default action without group', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const response = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send({ + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [ + { + id: 'test-id', + params: {}, + }, + ], + }, + ], + }); + + expect(response.status).to.eql(400); + + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Group is not defined in action test-id', + }); + }); + + it('should throw 400 if the system action is missing required params', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const response = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send({ + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [ + { + id: 'system-connector-test.system-action-connector-adapter', + params: {}, + }, + ], + }, + ], + }); + + expect(response.status).to.eql(200); + expect(response.body.errors.length).to.eql(1); + + expect(response.body.errors[0].message).to.eql( + 'Invalid system action params. System action type: test.system-action-connector-adapter - [myParam]: expected value of type [string] but got [undefined]' + ); + }); + + it('strips out properties from system actions that are part of the default actions', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + for (const propertyToAdd of [ + { group: 'default' }, + { + frequency: { + summary: false, + throttle: '1s', + notifyWhen: RuleNotifyWhen.THROTTLE, + }, + }, + ]) { + const systemActionWithProperty = { + id: 'system-connector-test.system-action', + params: {}, + ...propertyToAdd, + }; + + const response = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send({ + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [systemActionWithProperty], + }, + ], + }); + + expect(response.status).to.eql(200); + expect(response.body.rules[0].actions[0][Object.keys(propertyToAdd)[0]]).to.be(undefined); + + const esResponse = await es.get>( + { + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `alert:${response.body.rules[0].id}`, + }, + { meta: true } + ); + + expect(esResponse.statusCode).to.eql(200); + expect((esResponse.body._source as any)?.alert.systemActions).to.be(undefined); + + const rawActions = (esResponse.body._source as any)?.alert.actions ?? []; + expect(rawActions[0][Object.keys(propertyToAdd)[0]]).to.be(undefined); + } + }); + + it('should throw 400 when using the same system action twice', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const response = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send({ + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [ + { + id: 'system-connector-test.system-action', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }, + ], + }); + + expect(response.status).to.eql(200); + expect(response.body.errors.length).to.eql(1); + + expect(response.body.errors[0].message).to.eql('Cannot use the same system action twice'); + }); + }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_enable.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_enable.ts index 0366eca2ad24d..d61810bb72fd3 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_enable.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/bulk_enable.ts @@ -513,5 +513,71 @@ export default ({ getService }: FtrProviderContext) => { }); }); }); + + describe('Actions', () => { + const { user, space } = SuperuserAtSpace1; + + it('should return the actions correctly', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule1 } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: false, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }) + ) + .expect(200); + + objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting'); + + const response = await supertestWithoutAuth + .patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_enable`) + .set('kbn-xsrf', 'foo') + .send({ ids: [createdRule1.id] }) + .auth(user.username, user.password); + + const action = response.body.rules[0].actions[0]; + const systemAction = response.body.rules[0].actions[1]; + const { uuid, ...restAction } = action; + const { uuid: systemActionUuid, ...restSystemAction } = systemAction; + + expect([restAction, restSystemAction]).to.eql([ + { + id: createdAction.id, + actionTypeId: 'test.noop', + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + actionTypeId: 'test.system-action', + params: {}, + }, + , + ]); + }); + }); }); }; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/clone.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/clone.ts index 90748d1b2d4cd..d76c4dbd1a239 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/clone.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/clone.ts @@ -7,6 +7,8 @@ import expect from '@kbn/expect'; import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX, SavedObject } from '@kbn/core-saved-objects-server'; +import { RawRule } from '@kbn/alerting-plugin/server/types'; import { Spaces, UserAtSpaceScenarios } from '../../../scenarios'; import { checkAAD, @@ -249,5 +251,103 @@ export default function createAlertTests({ getService }: FtrProviderContext) { expect(cloneRuleResponse.body.scheduled_task_id).to.eql(undefined); }); + + describe('Actions', () => { + it('should clone a rule with actions correctly', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space1)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const ruleCreated = await supertest + .post(`${getUrlPrefix(space1)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }) + ); + + objectRemover.add(space1, ruleCreated.body.id, 'rule', 'alerting'); + + const cloneRuleResponse = await supertest + .post(`${getUrlPrefix(space1)}/internal/alerting/rule/${ruleCreated.body.id}/_clone`) + .set('kbn-xsrf', 'foo') + .send() + .expect(200); + + objectRemover.add(space1, cloneRuleResponse.body.id, 'rule', 'alerting'); + + const action = cloneRuleResponse.body.actions[0]; + const systemAction = cloneRuleResponse.body.actions[1]; + const { uuid, ...restAction } = action; + const { uuid: systemActionUuid, ...restSystemAction } = systemAction; + + expect([restAction, restSystemAction]).to.eql([ + { + id: createdAction.id, + connector_type_id: 'test.noop', + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + }, + , + ]); + + const esResponse = await es.get>( + { + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `alert:${cloneRuleResponse.body.id}`, + }, + { meta: true } + ); + + expect(esResponse.statusCode).to.eql(200); + expect((esResponse.body._source as any)?.alert.systemActions).to.be(undefined); + const rawActions = (esResponse.body._source as any)?.alert.actions ?? []; + + const rawAction = rawActions[0]; + const rawSystemAction = rawActions[1]; + + const { uuid: rawActionUuid, ...rawActionRest } = rawAction; + const { uuid: rawSystemActionUuid, ...rawSystemActionRest } = rawSystemAction; + + expect(rawActionRest).to.eql({ + actionRef: 'action_0', + actionTypeId: 'test.noop', + params: {}, + group: 'default', + }); + + expect(rawSystemActionRest).to.eql({ + actionRef: 'system_action:system-connector-test.system-action', + actionTypeId: 'test.system-action', + params: {}, + }); + + expect(rawActionUuid).to.not.be(undefined); + expect(rawSystemActionUuid).to.not.be(undefined); + }); + }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts index f9af26be6def7..999f4acbefa78 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts @@ -33,6 +33,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./user_managed_api_key')); loadTestFile(require.resolve('./get_query_delay_settings')); loadTestFile(require.resolve('./update_query_delay_settings')); + loadTestFile(require.resolve('./resolve')); }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/resolve.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/resolve.ts new file mode 100644 index 0000000000000..fe973b9dde73c --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/resolve.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { SuperuserAtSpace1 } from '../../../scenarios'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('resolve', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + describe('Actions', () => { + const { user, space } = SuperuserAtSpace1; + + it('should return the actions correctly', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule1 } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + enabled: true, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + params: {}, + }, + ], + }) + ) + .expect(200); + + objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/alerting/rule/${createdRule1.id}/_resolve`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + const action = response.body.actions[0]; + const systemAction = response.body.actions[1]; + const { uuid, ...restAction } = action; + const { uuid: systemActionUuid, ...restSystemAction } = systemAction; + + expect([restAction, restSystemAction]).to.eql([ + { + id: createdAction.id, + connector_type_id: 'test.noop', + group: 'default', + params: {}, + }, + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + }, + , + ]); + }); + }); + }); +}; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/alerts.ts index ab3c9ad93d54f..34f07f70f0216 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/alerts.ts @@ -14,7 +14,7 @@ import { TaskRunning, TaskRunningStage } from '@kbn/task-manager-plugin/server/t import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; -import { UserAtSpaceScenarios, Superuser } from '../../../scenarios'; +import { UserAtSpaceScenarios, Superuser, SuperuserAtSpace1 } from '../../../scenarios'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { getUrlPrefix, @@ -45,7 +45,9 @@ export default function alertTests({ getService }: FtrProviderContext) { await esTestIndexTool.setup(); await es.indices.create({ index: authorizationIndex }); }); + afterEach(() => objectRemover.removeAll()); + after(async () => { await esTestIndexTool.destroy(); await es.indices.delete({ index: authorizationIndex }); @@ -1873,6 +1875,76 @@ instanceStateValue: true }); }); } + + describe('connector adapters', () => { + const space = SuperuserAtSpace1.space; + + const connectorId = 'system-connector-test.system-action-connector-adapter'; + const name = 'System action: test.system-action-connector-adapter'; + + it('should use connector adapters correctly on system actions', async () => { + const alertUtils = new AlertUtils({ + supertestWithoutAuth, + objectRemover, + space, + user: SuperuserAtSpace1.user, + }); + + const startDate = new Date().toISOString(); + const reference = alertUtils.generateReference(); + /** + * Creates a rule that always fire with a system action + * that has configured a connector adapter. + * + * System action: x-pack/test/alerting_api_integration/common/plugins/alerts/server/action_types.ts + * Adapter: x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts + */ + const response = await alertUtils.createAlwaysFiringSystemAction({ + reference, + overwrites: { schedule: { interval: '1s' } }, + }); + + expect(response.status).to.eql(200); + + await validateSystemActionEventLog({ + spaceId: space.id, + connectorId, + outcome: 'success', + message: `action executed: test.system-action-connector-adapter:${connectorId}: ${name}`, + startDate, + }); + + /** + * The executor function of the system action + * writes the params in the test index. We + * get the doc to verify that the connector adapter + * injected the param correctly. + */ + await esTestIndexTool.waitForDocs( + 'action:test.system-action-connector-adapter', + reference, + 1 + ); + + const docs = await esTestIndexTool.search( + 'action:test.system-action-connector-adapter', + reference + ); + + const doc = docs.body.hits.hits[0]._source as { params: Record }; + + expect(doc.params).to.eql({ + myParam: 'param from rule action', + index: '.kibana-alerting-test-data', + reference: 'alert-utils-ref:1:superuser', + /** + * Param was injected by the connector adapter in + * x-pack/test/alerting_api_integration/common/plugins/alerts/server/connector_adapters.ts + */ + injected: 'param from connector adapter', + }); + }); + }); }); interface ValidateEventLogParams { @@ -1977,4 +2049,46 @@ instanceStateValue: true expect(event?.error?.message).to.eql(errorMessage); } } + + interface ValidateSystemActionEventLogParams { + spaceId: string; + connectorId: string; + outcome: string; + message: string; + startDate: string; + errorMessage?: string; + } + + const validateSystemActionEventLog = async ( + params: ValidateSystemActionEventLogParams + ): Promise => { + const { spaceId, connectorId, outcome, message, startDate, errorMessage } = params; + + const events: IValidatedEvent[] = await retry.try(async () => { + const events_ = await getEventLog({ + getService, + spaceId, + type: 'action', + id: connectorId, + provider: 'actions', + actions: new Map([['execute', { gte: 1 }]]), + }); + + const filteredEvents = events_.filter((event) => event!['@timestamp']! >= startDate); + if (filteredEvents.length < 1) throw new Error('no recent events found yet'); + + return filteredEvents; + }); + + expect(events.length).to.be(1); + + const event = events[0]; + + expect(event?.message).to.eql(message); + expect(event?.event?.outcome).to.eql(outcome); + + if (errorMessage) { + expect(event?.error?.message).to.eql(errorMessage); + } + }; } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/connector_types_system.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/connector_types_system.ts new file mode 100644 index 0000000000000..d1eb87310e444 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/connector_types_system.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { Spaces } from '../../scenarios'; +import { getUrlPrefix } from '../../../common/lib/space_test_utils'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function listActionTypesTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('connector_types', () => { + it('should return 200 with list of connector types containing defaults', async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/actions/connector_types` + ); + + function createActionTypeMatcher(id: string, name: string) { + return (actionType: { id: string; name: string }) => { + return actionType.id === id && actionType.name === name; + }; + } + + expect(response.status).to.eql(200); + // Check for values explicitly in order to avoid this test failing each time plugins register + // a new action type + expect( + response.body.some(createActionTypeMatcher('test.index-record', 'Test: Index Record')) + ).to.be(true); + }); + + it('should return system action types', async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/actions/connector_types` + ); + + const actionTypes = response.body as Array<{ is_system_action_type: boolean }>; + + expect(actionTypes.some((actionType) => actionType.is_system_action_type)).to.be(true); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts index fb10eba9774ad..f9b3d1c4120b6 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts @@ -31,6 +31,7 @@ export default function ({ getService }: FtrProviderContext) { await esTestIndexTool.setup(); await es.indices.create({ index: authorizationIndex }); }); + after(async () => { await esTestIndexTool.destroy(); await es.indices.delete({ index: authorizationIndex }); @@ -333,12 +334,20 @@ export default function ({ getService }: FtrProviderContext) { }); }); + /** + * The test are using a test endpoint that calls the actions client. + * The route is defined here x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts. + * The public execute API does not allows the execution of system actions. We use the + * test route to test the execution of system actions + */ it('should execute system actions correctly', async () => { const connectorId = 'system-connector-test.system-action'; const name = 'System action: test.system-action'; const response = await supertest - .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector/${connectorId}/_execute`) + .post( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts_fixture/${connectorId}/_execute_connector` + ) .set('kbn-xsrf', 'foo') .send({ params: {}, @@ -358,12 +367,20 @@ export default function ({ getService }: FtrProviderContext) { }); }); + /** + * The test are using a test endpoint that calls the actions client. + * The route is defined here x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts. + * The public execute API does not allows the execution of system actions. We use the + * test route to test the execution of system actions + */ it('should execute system actions with kibana privileges correctly', async () => { const connectorId = 'system-connector-test.system-action-kibana-privileges'; const name = 'System action: test.system-action-kibana-privileges'; const response = await supertest - .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector/${connectorId}/_execute`) + .post( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts_fixture/${connectorId}/_execute_connector` + ) .set('kbn-xsrf', 'foo') .send({ params: {}, @@ -382,6 +399,21 @@ export default function ({ getService }: FtrProviderContext) { spaceAgnostic: true, }); }); + + /** + * The public execute API does not allows the execution of system actions. + */ + it('should not allow the execution of system actions through the public execute endpoint', async () => { + const connectorId = 'system-connector-test.system-action-kibana-privileges'; + + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector/${connectorId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .expect(400); + }); }); interface ValidateEventLogParams { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts new file mode 100644 index 0000000000000..014a894db7d31 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts @@ -0,0 +1,313 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { Spaces } from '../../scenarios'; +import { getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function getAllActionTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('getAllSystem', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + it('should handle get all action request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); + + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/internal/actions/connectors`) + .expect(200); + + // the custom ssl connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomSslConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') + ); + + expect(nonCustomSslConnectors).to.eql([ + { + id: 'preconfigured-alert-history-es-index', + name: 'Alert history Elasticsearch index', + connector_type_id: '.index', + is_preconfigured: true, + is_deprecated: false, + is_system_action: false, + referenced_by_count: 0, + }, + { + id: createdAction.id, + is_preconfigured: false, + is_deprecated: false, + name: 'My action', + connector_type_id: 'test.index-record', + is_missing_secrets: false, + is_system_action: false, + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + referenced_by_count: 0, + }, + { + connector_type_id: '.email', + id: 'notification-email', + is_deprecated: false, + is_preconfigured: true, + is_system_action: false, + name: 'Notification Email Connector', + referenced_by_count: 0, + }, + { + id: 'preconfigured-es-index-action', + is_preconfigured: true, + is_deprecated: false, + is_system_action: false, + connector_type_id: '.index', + name: 'preconfigured_es_index_action', + referenced_by_count: 0, + }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow', + is_deprecated: true, + is_preconfigured: true, + is_system_action: false, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + is_system_action: false, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, + { + id: 'my-slack1', + is_preconfigured: true, + is_deprecated: false, + connector_type_id: '.slack', + is_system_action: false, + name: 'Slack#xyz', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action', + id: 'system-connector-test.system-action', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action-connector-adapter', + id: 'system-connector-test.system-action-connector-adapter', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action-connector-adapter', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action-kibana-privileges', + id: 'system-connector-test.system-action-kibana-privileges', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action-kibana-privileges', + referenced_by_count: 0, + }, + { + id: 'custom-system-abc-connector', + is_preconfigured: true, + is_deprecated: false, + connector_type_id: 'system-abc-action-type', + is_system_action: false, + name: 'SystemABC', + referenced_by_count: 0, + }, + { + id: 'preconfigured.test.index-record', + is_preconfigured: true, + is_deprecated: false, + connector_type_id: 'test.index-record', + is_system_action: false, + name: 'Test:_Preconfigured_Index_Record', + referenced_by_count: 0, + }, + { + id: 'my-test-email', + is_preconfigured: true, + is_deprecated: false, + is_system_action: false, + connector_type_id: '.email', + name: 'TestEmail#xyz', + referenced_by_count: 0, + }, + ]); + }); + + it(`shouldn't get all action from another space`, async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'My action', + actionTypeId: 'test.index-record', + config: { + unencrypted: `This value shouldn't get encrypted`, + }, + secrets: { + encrypted: 'This value should be encrypted', + }, + }) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions'); + + const { body: connectors } = await supertest + .get(`${getUrlPrefix(Spaces.other.id)}/internal/actions/connectors`) + .expect(200); + + // the custom ssl connectors have dynamic ports, so remove them before + // comparing to what we expect + const nonCustomSslConnectors = connectors.filter( + (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') + ); + + expect(nonCustomSslConnectors).to.eql([ + { + id: 'preconfigured-alert-history-es-index', + name: 'Alert history Elasticsearch index', + connector_type_id: '.index', + is_preconfigured: true, + is_deprecated: false, + is_system_action: false, + referenced_by_count: 0, + }, + { + connector_type_id: '.email', + id: 'notification-email', + is_deprecated: false, + is_preconfigured: true, + is_system_action: false, + name: 'Notification Email Connector', + referenced_by_count: 0, + }, + { + id: 'preconfigured-es-index-action', + is_preconfigured: true, + is_deprecated: false, + is_system_action: false, + connector_type_id: '.index', + name: 'preconfigured_es_index_action', + referenced_by_count: 0, + }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow', + is_deprecated: true, + is_preconfigured: true, + is_system_action: false, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, + { + connector_type_id: '.servicenow', + id: 'my-deprecated-servicenow-default', + is_preconfigured: true, + is_deprecated: true, + is_system_action: false, + name: 'ServiceNow#xyz', + referenced_by_count: 0, + }, + { + id: 'my-slack1', + is_preconfigured: true, + is_deprecated: false, + is_system_action: false, + connector_type_id: '.slack', + name: 'Slack#xyz', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action', + id: 'system-connector-test.system-action', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action-connector-adapter', + id: 'system-connector-test.system-action-connector-adapter', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action-connector-adapter', + referenced_by_count: 0, + }, + { + connector_type_id: 'test.system-action-kibana-privileges', + id: 'system-connector-test.system-action-kibana-privileges', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'System action: test.system-action-kibana-privileges', + referenced_by_count: 0, + }, + { + id: 'custom-system-abc-connector', + is_preconfigured: true, + is_deprecated: false, + is_system_action: false, + connector_type_id: 'system-abc-action-type', + name: 'SystemABC', + referenced_by_count: 0, + }, + { + id: 'preconfigured.test.index-record', + is_preconfigured: true, + is_deprecated: false, + is_system_action: false, + connector_type_id: 'test.index-record', + name: 'Test:_Preconfigured_Index_Record', + referenced_by_count: 0, + }, + { + id: 'my-test-email', + is_preconfigured: true, + is_deprecated: false, + is_system_action: false, + connector_type_id: '.email', + name: 'TestEmail#xyz', + referenced_by_count: 0, + }, + ]); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts index 4f5832debebda..c746186d9d100 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/index.ts @@ -17,8 +17,10 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); + loadTestFile(require.resolve('./get_all_system')); loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./connector_types')); + loadTestFile(require.resolve('./connector_types_system')); loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./monitoring_collection')); loadTestFile(require.resolve('./execute')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/create.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/create.ts index 3d97d39097348..555c578612225 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/create.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/create.ts @@ -7,8 +7,9 @@ import expect from '@kbn/expect'; import { SavedObject } from '@kbn/core/server'; -import { RawRule } from '@kbn/alerting-plugin/server/types'; +import { RawRule, RuleNotifyWhen } from '@kbn/alerting-plugin/server/types'; import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { omit } from 'lodash'; import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; import { Spaces } from '../../../scenarios'; import { @@ -158,11 +159,6 @@ export default function createAlertTests({ getService }: FtrProviderContext) { message: 'something important happened!', }, }, - { - id: 'system-connector-test.system-action', - group: 'default', - params: {}, - }, ], }) ); @@ -192,13 +188,6 @@ export default function createAlertTests({ getService }: FtrProviderContext) { }, uuid: response.body.actions[1].uuid, }, - { - id: 'system-connector-test.system-action', - group: 'default', - connector_type_id: 'test.system-action', - params: {}, - uuid: response.body.actions[2].uuid, - }, ], enabled: true, rule_type_id: 'test.noop', @@ -253,13 +242,6 @@ export default function createAlertTests({ getService }: FtrProviderContext) { }, uuid: rawActions[1].uuid, }, - { - actionRef: 'system_action:system-connector-test.system-action', - actionTypeId: 'test.system-action', - group: 'default', - params: {}, - uuid: rawActions[2].uuid, - }, ]); const references = esResponse.body._source?.references ?? []; @@ -465,6 +447,191 @@ export default function createAlertTests({ getService }: FtrProviderContext) { expect(response.body.scheduledTaskId).to.eql(undefined); }); + it('should not allow creating a default action without group', async () => { + const customId = '1'; + const response = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${customId}`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [ + { + // group is missing + id: 'test-id', + params: {}, + }, + ], + }) + ); + + expect(response.status).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Group is not defined in action test-id', + }); + }); + + describe('system actions', () => { + const systemAction = { + id: 'system-connector-test.system-action', + params: {}, + }; + + it('should create a rule with a system action correctly', async () => { + const response = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [systemAction], + }) + ); + + expect(response.status).to.eql(200); + expect(response.body.actions.length).to.eql(1); + + objectRemover.add(Spaces.space1.id, response.body.id, 'rule', 'alerting'); + + const action = response.body.actions[0]; + const { uuid, ...rest } = action; + + expect(rest).to.eql({ + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + }); + + expect(uuid).to.not.be(undefined); + + const esResponse = await es.get>( + { + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `alert:${response.body.id}`, + }, + { meta: true } + ); + + expect(esResponse.statusCode).to.eql(200); + expect((esResponse.body._source as any)?.alert.systemActions).to.be(undefined); + + const rawActions = (esResponse.body._source as any)?.alert.actions ?? []; + const rawAction = rawActions[0]; + const { uuid: rawActionUuid, ...rawActionRest } = rawAction; + + expect(rawActionRest).to.eql({ + actionRef: 'system_action:system-connector-test.system-action', + actionTypeId: 'test.system-action', + params: {}, + }); + + expect(uuid).to.not.be(undefined); + + const references = esResponse.body._source?.references ?? []; + + expect(references.length).to.eql(0); + }); + + it('should throw 400 if the system action is missing required properties', async () => { + for (const propertyToOmit of ['id']) { + const systemActionWithoutProperty = omit(systemAction, propertyToOmit); + + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [systemActionWithoutProperty], + }) + ) + .expect(400); + } + }); + + it('should throw 400 if the system action is missing required params', async () => { + const res = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [ + { + ...systemAction, + params: {}, + id: 'system-connector-test.system-action-connector-adapter', + actionTypeId: 'test.test.system-action-connector-adapter', + }, + ], + }) + ) + .expect(400); + + expect(res.body.message).to.eql( + 'Invalid system action params. System action type: test.system-action-connector-adapter - [myParam]: expected value of type [string] but got [undefined]' + ); + }); + + it('strips out properties from system actions that are part of the default actions', async () => { + for (const propertyToAdd of [ + { group: 'default' }, + { + frequency: { + summary: false, + throttle: '1s', + notify_when: RuleNotifyWhen.THROTTLE, + }, + }, + { + alerts_filter: { + query: { kql: 'kibana.alert.rule.name:abc', filters: [] }, + }, + }, + ]) { + const systemActionWithProperty = { ...systemAction, ...propertyToAdd }; + + const response = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [systemActionWithProperty], + }) + ); + + expect(response.status).to.eql(200); + expect(response.body.actions[0][Object.keys(propertyToAdd)[0]]).to.be(undefined); + + objectRemover.add(Spaces.space1.id, response.body.id, 'rule', 'alerting'); + + const esResponse = await es.get>( + { + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `alert:${response.body.id}`, + }, + { meta: true } + ); + + expect(esResponse.statusCode).to.eql(200); + expect((esResponse.body._source as any)?.alert.systemActions).to.be(undefined); + + const rawActions = (esResponse.body._source as any)?.alert.actions ?? []; + expect(rawActions[0][Object.keys(propertyToAdd)[0]]).to.be(undefined); + } + }); + + it('should throw 400 when using the same system action twice', async () => { + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + actions: [systemAction, systemAction], + }) + ) + .expect(400); + }); + }); + describe('legacy', () => { it('should handle create alert request appropriately', async () => { const { body: createdAction } = await supertest diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/bulk_edit.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/bulk_edit.ts new file mode 100644 index 0000000000000..b21485736f398 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/bulk_edit.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { omit } from 'lodash'; +import { Spaces } from '../../../scenarios'; +import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createUpdateTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('bulkEdit', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + describe('system actions', () => { + const systemAction = { + id: 'system-connector-test.system-action', + uuid: '123', + params: {}, + }; + + it('should bulk edit system actions correctly', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + const payload = { + ids: [rule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [systemAction], + }, + ], + }; + + const res = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload) + .expect(200); + + expect(res.body.rules[0].actions).to.eql([ + { + id: 'system-connector-test.system-action', + connector_type_id: 'test.system-action', + params: {}, + uuid: '123', + }, + ]); + }); + + it('should throw 400 if the system action is missing required properties', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + for (const propertyToOmit of ['id']) { + const systemActionWithoutProperty = omit(systemAction, propertyToOmit); + + const payload = { + ids: [rule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [systemActionWithoutProperty], + }, + ], + }; + + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload) + .expect(400); + } + }); + + it('should throw 400 if the system action is missing required params', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + const payload = { + ids: [rule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [ + { + ...systemAction, + params: {}, + id: 'system-connector-test.system-action-connector-adapter', + }, + ], + }, + ], + }; + + const res = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload) + .expect(200); + + expect(res.body.errors[0].message).to.eql( + 'Invalid system action params. System action type: test.system-action-connector-adapter - [myParam]: expected value of type [string] but got [undefined]' + ); + }); + + it('should throw 400 if the default action is missing the group', async () => { + const { body: rule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + const payload = { + ids: [rule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [ + { + // group is missing + id: 'test-id', + params: {}, + }, + ], + }, + ], + }; + + const response = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload); + + expect(response.status).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Group is not defined in action test-id', + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/index.ts index 1ccbb1c8f722d..5b03010a60235 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/index.ts @@ -27,5 +27,6 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./alerts_default_space')); loadTestFile(require.resolve('./transform_rule_types')); loadTestFile(require.resolve('./ml_rule_types')); + loadTestFile(require.resolve('./bulk_edit')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/update.ts index 623a2efc3f005..867e3c0ec8de0 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group2/update.ts @@ -128,6 +128,48 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { }); }); + it('should not allow updating default action without group', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + + objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting'); + + const updatedData = { + name: 'bcd', + tags: ['bar'], + params: { + foo: true, + risk_score: 40, + severity: 'medium', + }, + schedule: { interval: '12s' }, + actions: [ + { + // group is missing + id: 'test-id', + params: {}, + }, + ], + throttle: '1m', + notify_when: 'onThrottleInterval', + }; + + const response = await supertest + .put(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .send(updatedData); + + expect(response.status).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Group is not defined in action test-id', + }); + }); + describe('legacy', () => { it('should handle update alert request appropriately', async () => { const { body: createdAlert } = await supertest diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations.ts index b339f9492b054..2cb5a616320eb 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations.ts @@ -208,7 +208,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { }); it('7.16.0 migrates existing alerts to contain legacyId field', async () => { - const searchResult = await es.search( + const searchResult = await es.search<{ alert: RawRule }>( { index: ALERTING_CASES_SAVED_OBJECT_INDEX, body: { @@ -224,13 +224,11 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(searchResult.statusCode).toEqual(200); expect((searchResult.body.hits.total as estypes.SearchTotalHits).value).toEqual(1); const hit = searchResult.body.hits.hits[0]; - expect((hit!._source!.alert! as RawRule).legacyId).toEqual( - '74f3e6d7-b7bb-477d-ac28-92ee22728e6e' - ); + expect(hit!._source!.alert.legacyId).toEqual('74f3e6d7-b7bb-477d-ac28-92ee22728e6e'); }); it('7.16.0 migrates existing rules so predefined connectors are not stored in references', async () => { - const searchResult = await es.search( + const searchResult = await es.search<{ alert: RawRule; references: {} }>( { index: ALERTING_CASES_SAVED_OBJECT_INDEX, body: { @@ -246,7 +244,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(searchResult.statusCode).toEqual(200); expect((searchResult.body.hits.total as estypes.SearchTotalHits).value).toEqual(1); const hit = searchResult.body.hits.hits[0]; - expect((hit!._source!.alert! as RawRule).actions! as RawRuleAction[]).toEqual([ + expect(hit!._source!.alert.actions! as RawRuleAction[]).toEqual([ expect.objectContaining({ actionRef: 'action_0', actionTypeId: 'test.noop', @@ -448,7 +446,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { }); it('8.4.1 removes IsSnoozedUntil', async () => { - const searchResult = await es.search( + const searchResult = await es.search<{ alert: RawRule }>( { index: ALERTING_CASES_SAVED_OBJECT_INDEX, body: { @@ -464,7 +462,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(searchResult.statusCode).toEqual(200); const hit = searchResult.body.hits.hits[0]; - expect((hit!._source!.alert! as RawRule).isSnoozedUntil).toBe(undefined); + expect(hit!._source!.alert.isSnoozedUntil).toBe(undefined); }); it('8.5.0 removes runtime and field params from older ES Query rules', async () => { @@ -587,7 +585,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { }); it('8.7.0 adds aggType and groupBy to ES query rules', async () => { - const response = await es.search( + const response = await es.search<{ alert: RawRule }>( { index: ALERTING_CASES_SAVED_OBJECT_INDEX, body: { @@ -608,8 +606,8 @@ export default function createGetTests({ getService }: FtrProviderContext) { ); expect(response.statusCode).toEqual(200); response.body.hits.hits.forEach((hit) => { - expect((hit?._source?.alert as RawRule)?.params?.aggType).toEqual('count'); - expect((hit?._source?.alert as RawRule)?.params?.groupBy).toEqual('all'); + expect(hit?._source?.alert?.params?.aggType).toEqual('count'); + expect(hit?._source?.alert?.params?.groupBy).toEqual('all'); }); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations/8_2_0.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations/8_2_0.ts index a075fdaa387a5..7547682ebf485 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations/8_2_0.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/migrations/8_2_0.ts @@ -31,7 +31,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { describe('rule with null snoozeEndTime value', async () => { it('has snoozeEndTime removed', async () => { - const response = await es.get<{ alert: RawRule }>( + const response = await es.get<{ alert: RawRule & { snoozeEndTime?: string } }>( { index: ALERTING_CASES_SAVED_OBJECT_INDEX, id: 'alert:bdfce750-fba0-11ec-9157-2f379249da99', @@ -62,7 +62,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { describe('rules with snoozeEndTime value', async () => { it('has snoozeEndTime migrated to snoozeSchedule', async () => { - const response = await es.get<{ alert: RawRule }>( + const response = await es.get<{ alert: RawRule & { snoozeEndTime?: string } }>( { index: ALERTING_CASES_SAVED_OBJECT_INDEX, id: 'alert:402084f0-fbb8-11ec-856c-39466bd4c433', diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 17eea1bbd8f93..70fb283c1c4e8 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -171,5 +171,6 @@ "@kbn/observability-ai-assistant-app-plugin", "@kbn/aiops-log-rate-analysis", "@kbn/apm-data-view", + "@kbn/core-saved-objects-api-server", ] }