diff --git a/x-pack/plugins/alerts/common/alert_instance.ts b/x-pack/plugins/alerts/common/alert_instance.ts index 0253a5c6919d6..179a157cdb170 100644 --- a/x-pack/plugins/alerts/common/alert_instance.ts +++ b/x-pack/plugins/alerts/common/alert_instance.ts @@ -7,10 +7,15 @@ import * as t from 'io-ts'; import { DateFromString } from './date_from_string'; const metaSchema = t.partial({ - lastScheduledActions: t.type({ - group: t.string, - date: DateFromString, - }), + lastScheduledActions: t.intersection([ + t.partial({ + subgroup: t.string, + }), + t.type({ + group: t.string, + date: DateFromString, + }), + ]), }); export type AlertInstanceMeta = t.TypeOf; diff --git a/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts b/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts index c5f93edfb74e5..e680f22afad8e 100644 --- a/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts +++ b/x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts @@ -174,6 +174,134 @@ describe('scheduleActions()', () => { }); }); +describe('scheduleActionsWithSubGroup()', () => { + test('makes hasScheduledActions() return true', () => { + const alertInstance = new AlertInstance({ + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance + .replaceState({ otherField: true }) + .scheduleActionsWithSubGroup('default', 'subgroup', { field: true }); + expect(alertInstance.hasScheduledActions()).toEqual(true); + }); + + test('makes isThrottled() return true when throttled and subgroup is the same', () => { + const alertInstance = new AlertInstance({ + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'subgroup', + }, + }, + }); + alertInstance + .replaceState({ otherField: true }) + .scheduleActionsWithSubGroup('default', 'subgroup', { field: true }); + expect(alertInstance.isThrottled('1m')).toEqual(true); + }); + + test('makes isThrottled() return true when throttled and last schedule had no subgroup', () => { + const alertInstance = new AlertInstance({ + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alertInstance + .replaceState({ otherField: true }) + .scheduleActionsWithSubGroup('default', 'subgroup', { field: true }); + expect(alertInstance.isThrottled('1m')).toEqual(true); + }); + + test('makes isThrottled() return false when throttled and subgroup is the different', () => { + const alertInstance = new AlertInstance({ + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + subgroup: 'prev-subgroup', + }, + }, + }); + alertInstance + .replaceState({ otherField: true }) + .scheduleActionsWithSubGroup('default', 'subgroup', { field: true }); + expect(alertInstance.isThrottled('1m')).toEqual(false); + }); + + test('make isThrottled() return false when throttled expired', () => { + const alertInstance = new AlertInstance({ + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + clock.tick(120000); + alertInstance + .replaceState({ otherField: true }) + .scheduleActionsWithSubGroup('default', 'subgroup', { field: true }); + expect(alertInstance.isThrottled('1m')).toEqual(false); + }); + + test('makes getScheduledActionOptions() return given options', () => { + const alertInstance = new AlertInstance({ state: { foo: true }, meta: {} }); + alertInstance + .replaceState({ otherField: true }) + .scheduleActionsWithSubGroup('default', 'subgroup', { field: true }); + expect(alertInstance.getScheduledActionOptions()).toEqual({ + actionGroup: 'default', + subgroup: 'subgroup', + context: { field: true }, + state: { otherField: true }, + }); + }); + + test('cannot schdule for execution twice', () => { + const alertInstance = new AlertInstance(); + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup', { field: true }); + expect(() => + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup', { field: false }) + ).toThrowErrorMatchingInlineSnapshot( + `"Alert instance execution has already been scheduled, cannot schedule twice"` + ); + }); + + test('cannot schdule for execution twice with different subgroups', () => { + const alertInstance = new AlertInstance(); + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup', { field: true }); + expect(() => + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup', { field: false }) + ).toThrowErrorMatchingInlineSnapshot( + `"Alert instance execution has already been scheduled, cannot schedule twice"` + ); + }); + + test('cannot schdule for execution twice whether there are subgroups', () => { + const alertInstance = new AlertInstance(); + alertInstance.scheduleActions('default', { field: true }); + expect(() => + alertInstance.scheduleActionsWithSubGroup('default', 'subgroup', { field: false }) + ).toThrowErrorMatchingInlineSnapshot( + `"Alert instance execution has already been scheduled, cannot schedule twice"` + ); + }); +}); + describe('replaceState()', () => { test('replaces previous state', () => { const alertInstance = new AlertInstance({ state: { foo: true } }); diff --git a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts index 0416c26d6bc35..6734e7249ebb7 100644 --- a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts +++ b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts @@ -13,21 +13,32 @@ import { import { parseDuration } from '../lib'; -export type AlertInstances< +interface ScheduledExecutionOptions< + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string +> { + actionGroup: ActionGroupIds; + subgroup?: string; + context: Context; + state: State; +} + +export type PublicAlertInstance< State extends AlertInstanceState = AlertInstanceState, Context extends AlertInstanceContext = AlertInstanceContext, ActionGroupIds extends string = string -> = Record>; +> = Pick< + AlertInstance, + 'getState' | 'replaceState' | 'scheduleActions' | 'scheduleActionsWithSubGroup' +>; + export class AlertInstance< State extends AlertInstanceState = AlertInstanceState, Context extends AlertInstanceContext = AlertInstanceContext, ActionGroupIds extends string = string > { - private scheduledExecutionOptions?: { - actionGroup: ActionGroupIds; - context: Context; - state: State; - }; + private scheduledExecutionOptions?: ScheduledExecutionOptions; private meta: AlertInstanceMeta; private state: State; @@ -45,10 +56,16 @@ export class AlertInstance< return false; } const throttleMills = throttle ? parseDuration(throttle) : 0; - const actionGroup = this.scheduledExecutionOptions.actionGroup; if ( this.meta.lastScheduledActions && - this.meta.lastScheduledActions.group === actionGroup && + this.scheduledActionGroupIsUnchanged( + this.meta.lastScheduledActions, + this.scheduledExecutionOptions + ) && + this.scheduledActionSubgroupIsUnchanged( + this.meta.lastScheduledActions, + this.scheduledExecutionOptions + ) && this.meta.lastScheduledActions.date.getTime() + throttleMills > Date.now() ) { return true; @@ -56,6 +73,22 @@ export class AlertInstance< return false; } + private scheduledActionGroupIsUnchanged( + lastScheduledActions: NonNullable, + scheduledExecutionOptions: ScheduledExecutionOptions + ) { + return lastScheduledActions.group === scheduledExecutionOptions.actionGroup; + } + + private scheduledActionSubgroupIsUnchanged( + lastScheduledActions: NonNullable, + scheduledExecutionOptions: ScheduledExecutionOptions + ) { + return lastScheduledActions.subgroup && scheduledExecutionOptions.subgroup + ? lastScheduledActions.subgroup === scheduledExecutionOptions.subgroup + : true; + } + getLastScheduledActions() { return this.meta.lastScheduledActions; } @@ -74,24 +107,43 @@ export class AlertInstance< } scheduleActions(actionGroup: ActionGroupIds, context: Context = {} as Context) { - if (this.hasScheduledActions()) { - throw new Error('Alert instance execution has already been scheduled, cannot schedule twice'); - } + this.ensureHasNoScheduledActions(); + this.scheduledExecutionOptions = { + actionGroup, + context, + state: this.state, + }; + return this; + } + + scheduleActionsWithSubGroup( + actionGroup: ActionGroupIds, + subgroup: string, + context: Context = {} as Context + ) { + this.ensureHasNoScheduledActions(); this.scheduledExecutionOptions = { actionGroup, + subgroup, context, state: this.state, }; return this; } + private ensureHasNoScheduledActions() { + if (this.hasScheduledActions()) { + throw new Error('Alert instance execution has already been scheduled, cannot schedule twice'); + } + } + replaceState(state: State) { this.state = state; return this; } - updateLastScheduledActions(group: string) { - this.meta.lastScheduledActions = { group, date: new Date() }; + updateLastScheduledActions(group: string, subgroup?: string) { + this.meta.lastScheduledActions = { group, subgroup, date: new Date() }; } /** diff --git a/x-pack/plugins/alerts/server/alert_instance/index.ts b/x-pack/plugins/alerts/server/alert_instance/index.ts index 40ee0874e805c..d371eb3bd3dcf 100644 --- a/x-pack/plugins/alerts/server/alert_instance/index.ts +++ b/x-pack/plugins/alerts/server/alert_instance/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AlertInstance } from './alert_instance'; +export { AlertInstance, PublicAlertInstance } from './alert_instance'; export { createAlertInstanceFactory } from './create_alert_instance_factory'; diff --git a/x-pack/plugins/alerts/server/index.ts b/x-pack/plugins/alerts/server/index.ts index 64e585da5c654..7bb54cd87bc33 100644 --- a/x-pack/plugins/alerts/server/index.ts +++ b/x-pack/plugins/alerts/server/index.ts @@ -28,7 +28,7 @@ export { } from './types'; export { PluginSetupContract, PluginStartContract } from './plugin'; export { FindResult } from './alerts_client'; -export { AlertInstance } from './alert_instance'; +export { PublicAlertInstance as AlertInstance } from './alert_instance'; export { parseDuration } from './lib'; export const plugin = (initContext: PluginInitializerContext) => new AlertingPlugin(initContext); diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index ccd1f6c20ba52..d036e76644750 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -39,6 +39,7 @@ interface CreateExecutionHandlerOptions { interface ExecutionHandlerOptions { actionGroup: string; + actionSubgroup?: string; alertInstanceId: string; context: AlertInstanceContext; state: AlertInstanceState; @@ -59,7 +60,13 @@ export function createExecutionHandler({ alertParams, }: CreateExecutionHandlerOptions) { const alertTypeActionGroups = new Set(map(alertType.actionGroups, 'id')); - return async ({ actionGroup, context, state, alertInstanceId }: ExecutionHandlerOptions) => { + return async ({ + actionGroup, + actionSubgroup, + context, + state, + alertInstanceId, + }: ExecutionHandlerOptions) => { if (!alertTypeActionGroups.has(actionGroup)) { logger.error(`Invalid action group "${actionGroup}" for alert "${alertType.id}".`); return; @@ -76,6 +83,7 @@ export function createExecutionHandler({ tags, alertInstanceId, alertActionGroup: actionGroup, + alertActionSubgroup: actionSubgroup, context, actionParams: action.params, state, diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 24d96788c3395..eb2b3ab41f179 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -152,10 +152,15 @@ export class TaskRunner { alertInstance: AlertInstance, executionHandler: ReturnType ) { - const { actionGroup, context, state } = alertInstance.getScheduledActionOptions()!; - alertInstance.updateLastScheduledActions(actionGroup); + const { + actionGroup, + subgroup: actionSubgroup, + context, + state, + } = alertInstance.getScheduledActionOptions()!; + alertInstance.updateLastScheduledActions(actionGroup, actionSubgroup); alertInstance.unscheduleActions(); - return executionHandler({ actionGroup, context, state, alertInstanceId }); + return executionHandler({ actionGroup, actionSubgroup, context, state, alertInstanceId }); } async executeAlertInstances( diff --git a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts index b02285d56aa9a..f3ed512240de5 100644 --- a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts +++ b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts @@ -20,6 +20,7 @@ interface TransformActionParamsOptions { tags?: string[]; alertInstanceId: string; alertActionGroup: string; + alertActionSubgroup?: string; actionParams: AlertActionParams; alertParams: AlertTypeParams; state: AlertInstanceState; @@ -33,6 +34,7 @@ export function transformActionParams({ tags, alertInstanceId, alertActionGroup, + alertActionSubgroup, context, actionParams, state, @@ -51,6 +53,7 @@ export function transformActionParams({ tags, alertInstanceId, alertActionGroup, + alertActionSubgroup, context, date: new Date().toISOString(), state, diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index 04adcb025699b..92bddb40dce0d 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { AlertInstance } from './alert_instance'; +import { PublicAlertInstance } from './alert_instance'; import { AlertTypeRegistry as OrigAlertTypeRegistry } from './alert_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { AlertsClient } from './alerts_client'; @@ -58,7 +58,7 @@ export interface AlertServices< > extends Services { alertInstanceFactory: ( id: string - ) => AlertInstance; + ) => PublicAlertInstance; } export interface AlertExecutorOptions< diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts index 998ec6ab2ed0e..b56edadad939a 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts @@ -62,21 +62,35 @@ function getAlwaysFiringAlertType() { updatedBy, } = alertExecutorOptions; let group: string | null = 'default'; + let subgroup: string | null = null; const alertInfo = { alertId, spaceId, namespace, name, tags, createdBy, updatedBy }; if (params.groupsToScheduleActionsInSeries) { const index = state.groupInSeriesIndex || 0; - group = params.groupsToScheduleActionsInSeries[index]; + const [scheduledGroup, scheduledSubgroup] = ( + params.groupsToScheduleActionsInSeries[index] ?? '' + ).split(':'); + + group = scheduledGroup; + subgroup = scheduledSubgroup; } if (group) { - services + const instance = services .alertInstanceFactory('1') - .replaceState({ instanceStateValue: true }) - .scheduleActions(group, { + .replaceState({ instanceStateValue: true }); + + if (subgroup) { + instance.scheduleActionsWithSubGroup(group, subgroup, { instanceContextValue: true, }); + } else { + instance.scheduleActions(group, { + instanceContextValue: true, + }); + } } + await services.scopedClusterClient.index({ index: params.index, refresh: 'wait_for', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 0820b7642e99e..11d3e6894e1a7 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -841,6 +841,80 @@ instanceStateValue: true } }); + it('should not throttle when changing subgroups', async () => { + const testStart = new Date(); + const reference = alertUtils.generateReference(); + const response = await alertUtils.createAlwaysFiringAction({ + reference, + overwrites: { + schedule: { interval: '1s' }, + params: { + index: ES_TEST_INDEX_NAME, + reference, + groupsToScheduleActionsInSeries: ['default:prev', 'default:next'], + }, + actions: [ + { + group: 'default', + id: indexRecordActionId, + params: { + index: ES_TEST_INDEX_NAME, + reference, + message: 'from:{{alertActionGroup}}:{{alertActionSubgroup}}', + }, + }, + ], + }, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + case 'superuser at space1': + expect(response.statusCode).to.eql(200); + // Wait for actions to execute twice before disabling the alert and waiting for tasks to finish + await esTestIndexTool.waitForDocs('action:test.index-record', reference, 2); + await alertUtils.disable(response.body.id); + await taskManagerUtils.waitForEmpty(testStart); + + // Ensure only 2 actions with proper params exists + const searchResult = await esTestIndexTool.search( + 'action:test.index-record', + reference + ); + expect(searchResult.hits.total.value).to.eql(2); + const messages: string[] = searchResult.hits.hits.map( + (hit: { _source: { params: { message: string } } }) => hit._source.params.message + ); + expect(messages.sort()).to.eql(['from:default:next', 'from:default:prev']); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it('should reset throttle window when not firing', async () => { const testStart = new Date(); const reference = alertUtils.generateReference();