diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 58f61b79f3ba6..58677141ab0c8 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -213,6 +213,10 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | User has accessed a rule. | `failure` | User is not authorized to access a rule. +.2+| `rule_get_execution_log` +| `success` | User has accessed execution log for a rule. +| `failure` | User is not authorized to access execution log for a rule. + .2+| `rule_find` | `success` | User has accessed a rule as part of a search operation. | `failure` | User is not authorized to search for rules. diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 903d190a216bd..6dde7de84aab4 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -643,6 +643,7 @@ When a user is granted the `read` role in the Alerting Framework, they will be a - `get` - `getRuleState` - `getAlertSummary` +- `getExecutionLog` - `find` When a user is granted the `all` role in the Alerting Framework, they will be able to execute all of the `read` privileged api calls, but in addition they'll be granted the following calls: diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index c275053874efa..546fd3e4aed9a 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -30,6 +30,7 @@ export enum ReadOperations { Get = 'get', GetRuleState = 'getRuleState', GetAlertSummary = 'getAlertSummary', + GetExecutionLog = 'getExecutionLog', Find = 'find', } diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts new file mode 100644 index 0000000000000..92999a80f6b99 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts @@ -0,0 +1,887 @@ +/* + * 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 { + getNumExecutions, + getExecutionLogAggregation, + formatExecutionLogResult, + formatSortForBucketSort, + formatSortForTermSort, +} from './get_execution_log_aggregation'; + +describe('formatSortForBucketSort', () => { + test('should correctly format array of sort combinations for bucket sorting', () => { + expect( + formatSortForBucketSort([ + { timestamp: { order: 'desc' } }, + { execution_duration: { order: 'asc' } }, + ]) + ).toEqual([ + { 'ruleExecution>executeStartTime': { order: 'desc' } }, + { 'ruleExecution>executionDuration': { order: 'asc' } }, + ]); + }); +}); + +describe('formatSortForTermSort', () => { + test('should correctly format array of sort combinations for bucket sorting', () => { + expect( + formatSortForTermSort([ + { timestamp: { order: 'desc' } }, + { execution_duration: { order: 'asc' } }, + ]) + ).toEqual([ + { 'ruleExecution>executeStartTime': 'desc' }, + { 'ruleExecution>executionDuration': 'asc' }, + ]); + }); +}); + +describe('getNumExecutions', () => { + test('should calculate the expected number of executions in a given date range with a given schedule interval', () => { + expect( + getNumExecutions( + new Date('2020-12-01T00:00:00.000Z'), + new Date('2020-12-02T00:00:00.000Z'), + '1h' + ) + ).toEqual(24); + }); + + test('should return 0 if dateEnd is less that dateStart', () => { + expect( + getNumExecutions( + new Date('2020-12-02T00:00:00.000Z'), + new Date('2020-12-01T00:00:00.000Z'), + '1h' + ) + ).toEqual(0); + }); + + test('should cap numExecutions at default max buckets limit', () => { + expect( + getNumExecutions( + new Date('2020-12-01T00:00:00.000Z'), + new Date('2020-12-02T00:00:00.000Z'), + '1s' + ) + ).toEqual(1000); + }); +}); + +describe('getExecutionLogAggregation', () => { + test('should throw error when given bad sort field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ notsortable: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions]"` + ); + }); + + test('should throw error when given one bad sort field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ notsortable: { order: 'asc' } }, { timestamp: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions]"` + ); + }); + + test('should throw error when given bad page field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 0, + perPage: 10, + sort: [{ timestamp: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot(`"Invalid page field \\"0\\" - must be greater than 0"`); + }); + + test('should throw error when given bad perPage field', () => { + expect(() => { + getExecutionLogAggregation({ + page: 1, + perPage: 0, + sort: [{ timestamp: { order: 'asc' } }], + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Invalid perPage field \\"0\\" - must be greater than 0"` + ); + }); + + test('should correctly generate aggregation', () => { + expect( + getExecutionLogAggregation({ + page: 2, + perPage: 10, + sort: [{ timestamp: { order: 'asc' } }, { execution_duration: { order: 'desc' } }], + }) + ).toEqual({ + executionUuidCardinality: { cardinality: { field: 'kibana.alert.rule.execution.uuid' } }, + executionUuid: { + terms: { + field: 'kibana.alert.rule.execution.uuid', + size: 1000, + order: [ + { 'ruleExecution>executeStartTime': 'asc' }, + { 'ruleExecution>executionDuration': 'desc' }, + ], + }, + aggs: { + executionUuidSorted: { + bucket_sort: { + sort: [ + { 'ruleExecution>executeStartTime': { order: 'asc' } }, + { 'ruleExecution>executionDuration': { order: 'desc' } }, + ], + from: 10, + size: 10, + gap_policy: 'insert_zeros', + }, + }, + alertCounts: { + filters: { + filters: { + newAlerts: { match: { 'event.action': 'new-instance' } }, + activeAlerts: { match: { 'event.action': 'active-instance' } }, + recoveredAlerts: { match: { 'event.action': 'recovered-instance' } }, + }, + }, + }, + actionExecution: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute' } }, + { match: { 'event.provider': 'actions' } }, + ], + }, + }, + aggs: { actionOutcomes: { terms: { field: 'event.outcome', size: 2 } } }, + }, + ruleExecution: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute' } }, + { match: { 'event.provider': 'alerting' } }, + ], + }, + }, + aggs: { + executeStartTime: { min: { field: 'event.start' } }, + scheduleDelay: { + max: { + field: 'kibana.task.schedule_delay', + }, + }, + totalSearchDuration: { + max: { field: 'kibana.alert.rule.execution.metrics.total_search_duration_ms' }, + }, + esSearchDuration: { + max: { field: 'kibana.alert.rule.execution.metrics.es_search_duration_ms' }, + }, + numTriggeredActions: { + max: { field: 'kibana.alert.rule.execution.metrics.number_of_triggered_actions' }, + }, + executionDuration: { max: { field: 'event.duration' } }, + outcomeAndMessage: { + top_hits: { size: 1, _source: { includes: ['event.outcome', 'message'] } }, + }, + }, + }, + timeoutMessage: { + filter: { + bool: { + must: [ + { match: { 'event.action': 'execute-timeout' } }, + { match: { 'event.provider': 'alerting' } }, + ], + }, + }, + }, + }, + }, + }); + }); +}); + +describe('formatExecutionLogResult', () => { + test('should return empty results if aggregations are undefined', () => { + expect(formatExecutionLogResult({ aggregations: undefined })).toEqual({ + total: 0, + data: [], + }); + }); + test('should format results correctly', () => { + const results = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + doc_count: 27, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 0, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'S4wIZX8B8TGQpG7XQZns', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.074e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.056e9, + }, + executeStartTime: { + value: 1.646667512617e12, + value_as_string: '2022-03-07T15:38:32.617Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 374, + }, + }, + }; + expect(formatExecutionLogResult(results)).toEqual({ + total: 374, + data: [ + { + id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + timestamp: '2022-03-07T15:38:32.617Z', + duration_ms: 1056, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 0, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3074, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + ], + }); + }); + + test('should format results correctly when execution timeouts occur', () => { + const results = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '09b5aeab-d50d-43b2-88e7-f1a20f682b3f', + doc_count: 3, + timeoutMessage: { + meta: {}, + doc_count: 1, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 0, + }, + newAlerts: { + doc_count: 0, + }, + recoveredAlerts: { + doc_count: 0, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 0.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'dJkWa38B1ylB1EvsAckB', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.074e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.0279e10, + }, + executeStartTime: { + value: 1.646769067607e12, + value_as_string: '2022-03-08T19:51:07.607Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 0, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, + }, + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 374, + }, + }, + }; + expect(formatExecutionLogResult(results)).toEqual({ + total: 374, + data: [ + { + id: '09b5aeab-d50d-43b2-88e7-f1a20f682b3f', + timestamp: '2022-03-08T19:51:07.607Z', + duration_ms: 10279, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 0, + num_new_alerts: 0, + num_recovered_alerts: 0, + num_triggered_actions: 0, + num_succeeded_actions: 0, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: true, + schedule_delay_ms: 3074, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + ], + }); + }); + + test('should format results correctly when action errors occur', () => { + const results = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'ecf7ac4c-1c15-4a1d-818a-cacbf57f6158', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: '7xKcb38BcntAq5ycFwiu', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.374e9, + }, + executeStartTime: { + value: 1.646844973039e12, + value_as_string: '2022-03-09T16:56:13.039Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'failure', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '61bb867b-661a-471f-bf92-23471afa10b3', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'zRKbb38BcntAq5ycOwgk', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.133e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 4.18e8, + }, + executeStartTime: { + value: 1.646844917518e12, + value_as_string: '2022-03-09T16:55:17.518Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 417, + }, + }, + }; + expect(formatExecutionLogResult(results)).toEqual({ + total: 417, + data: [ + { + id: 'ecf7ac4c-1c15-4a1d-818a-cacbf57f6158', + timestamp: '2022-03-09T16:56:13.039Z', + duration_ms: 1374, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 0, + num_errored_actions: 5, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + { + id: '61bb867b-661a-471f-bf92-23471afa10b3', + timestamp: '2022-03-09T16:55:17.518Z', + duration_ms: 418, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3133, + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts new file mode 100644 index 0000000000000..445cec6ad8412 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts @@ -0,0 +1,325 @@ +/* + * 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 type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import Boom from '@hapi/boom'; +import { flatMap, get } from 'lodash'; +import { parseDuration } from '.'; +import { AggregateEventsBySavedObjectResult } from '../../../event_log/server'; + +const DEFAULT_MAX_BUCKETS_LIMIT = 1000; // do not retrieve more than this number of executions + +const PROVIDER_FIELD = 'event.provider'; +const START_FIELD = 'event.start'; +const ACTION_FIELD = 'event.action'; +const OUTCOME_FIELD = 'event.outcome'; +const DURATION_FIELD = 'event.duration'; +const MESSAGE_FIELD = 'message'; +const SCHEDULE_DELAY_FIELD = 'kibana.task.schedule_delay'; +const ES_SEARCH_DURATION_FIELD = 'kibana.alert.rule.execution.metrics.es_search_duration_ms'; +const TOTAL_SEARCH_DURATION_FIELD = 'kibana.alert.rule.execution.metrics.total_search_duration_ms'; +const NUMBER_OF_TRIGGERED_ACTIONS_FIELD = + 'kibana.alert.rule.execution.metrics.number_of_triggered_actions'; +const EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid'; + +const Millis2Nanos = 1000 * 1000; + +export interface IExecutionLog { + id: string; + timestamp: string; + duration_ms: number; + status: string; + message: string; + num_active_alerts: number; + num_new_alerts: number; + num_recovered_alerts: number; + num_triggered_actions: number; + num_succeeded_actions: number; + num_errored_actions: number; + total_search_duration_ms: number; + es_search_duration_ms: number; + schedule_delay_ms: number; + timed_out: boolean; +} + +export interface IExecutionLogResult { + total: number; + data: IExecutionLog[]; +} + +interface IAlertCounts extends estypes.AggregationsMultiBucketAggregateBase { + buckets: { + activeAlerts: estypes.AggregationsSingleBucketAggregateBase; + newAlerts: estypes.AggregationsSingleBucketAggregateBase; + recoveredAlerts: estypes.AggregationsSingleBucketAggregateBase; + }; +} + +interface IActionExecution + extends estypes.AggregationsTermsAggregateBase<{ key: string; doc_count: number }> { + buckets: Array<{ key: string; doc_count: number }>; +} + +interface IExecutionUuidAggBucket extends estypes.AggregationsStringTermsBucketKeys { + timeoutMessage: estypes.AggregationsMultiBucketBase; + ruleExecution: { + executeStartTime: estypes.AggregationsMinAggregate; + executionDuration: estypes.AggregationsMaxAggregate; + scheduleDelay: estypes.AggregationsMaxAggregate; + esSearchDuration: estypes.AggregationsMaxAggregate; + totalSearchDuration: estypes.AggregationsMaxAggregate; + numTriggeredActions: estypes.AggregationsMaxAggregate; + outcomeAndMessage: estypes.AggregationsTopHitsAggregate; + }; + alertCounts: IAlertCounts; + actionExecution: { + actionOutcomes: IActionExecution; + }; +} + +interface ExecutionUuidAggResult + extends estypes.AggregationsAggregateBase { + buckets: TBucket[]; +} +export interface IExecutionLogAggOptions { + page: number; + perPage: number; + sort: estypes.Sort; +} + +const ExecutionLogSortFields: Record = { + timestamp: 'ruleExecution>executeStartTime', + execution_duration: 'ruleExecution>executionDuration', + total_search_duration: 'ruleExecution>totalSearchDuration', + es_search_duration: 'ruleExecution>esSearchDuration', + schedule_delay: 'ruleExecution>scheduleDelay', + num_triggered_actions: 'ruleExecution>numTriggeredActions', +}; + +export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLogAggOptions) { + // Check if valid sort fields + const sortFields = flatMap(sort as estypes.SortCombinations[], (s) => Object.keys(s)); + for (const field of sortFields) { + if (!Object.keys(ExecutionLogSortFields).includes(field)) { + throw Boom.badRequest( + `Invalid sort field "${field}" - must be one of [${Object.keys(ExecutionLogSortFields).join( + ',' + )}]` + ); + } + } + + // Check if valid page value + if (page <= 0) { + throw Boom.badRequest(`Invalid page field "${page}" - must be greater than 0`); + } + + // Check if valid page value + if (perPage <= 0) { + throw Boom.badRequest(`Invalid perPage field "${perPage}" - must be greater than 0`); + } + + return { + // Get total number of executions + executionUuidCardinality: { + cardinality: { + field: EXECUTION_UUID_FIELD, + }, + }, + executionUuid: { + // Bucket by execution UUID + terms: { + field: EXECUTION_UUID_FIELD, + size: DEFAULT_MAX_BUCKETS_LIMIT, + order: formatSortForTermSort(sort), + }, + aggs: { + // Bucket sort to allow paging through executions + executionUuidSorted: { + bucket_sort: { + sort: formatSortForBucketSort(sort), + from: (page - 1) * perPage, + size: perPage, + gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy, + }, + }, + // Get counts for types of alerts and whether there was an execution timeout + alertCounts: { + filters: { + filters: { + newAlerts: { match: { [ACTION_FIELD]: 'new-instance' } }, + activeAlerts: { match: { [ACTION_FIELD]: 'active-instance' } }, + recoveredAlerts: { match: { [ACTION_FIELD]: 'recovered-instance' } }, + }, + }, + }, + // Filter by action execute doc and get information from this event + actionExecution: { + filter: getProviderAndActionFilter('actions', 'execute'), + aggs: { + actionOutcomes: { + terms: { + field: OUTCOME_FIELD, + size: 2, + }, + }, + }, + }, + // Filter by rule execute doc and get information from this event + ruleExecution: { + filter: getProviderAndActionFilter('alerting', 'execute'), + aggs: { + executeStartTime: { + min: { + field: START_FIELD, + }, + }, + scheduleDelay: { + max: { + field: SCHEDULE_DELAY_FIELD, + }, + }, + totalSearchDuration: { + max: { + field: TOTAL_SEARCH_DURATION_FIELD, + }, + }, + esSearchDuration: { + max: { + field: ES_SEARCH_DURATION_FIELD, + }, + }, + numTriggeredActions: { + max: { + field: NUMBER_OF_TRIGGERED_ACTIONS_FIELD, + }, + }, + executionDuration: { + max: { + field: DURATION_FIELD, + }, + }, + outcomeAndMessage: { + top_hits: { + size: 1, + _source: { + includes: [OUTCOME_FIELD, MESSAGE_FIELD], + }, + }, + }, + }, + }, + // If there was a timeout, this filter will return non-zero doc count + timeoutMessage: { + filter: getProviderAndActionFilter('alerting', 'execute-timeout'), + }, + }, + }, + }; +} + +function getProviderAndActionFilter(provider: string, action: string) { + return { + bool: { + must: [ + { + match: { + [ACTION_FIELD]: action, + }, + }, + { + match: { + [PROVIDER_FIELD]: provider, + }, + }, + ], + }, + }; +} + +function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutionLog { + const durationUs = bucket?.ruleExecution?.executionDuration?.value + ? bucket.ruleExecution.executionDuration.value + : 0; + const scheduleDelayUs = bucket?.ruleExecution?.scheduleDelay?.value + ? bucket.ruleExecution.scheduleDelay.value + : 0; + const timedOut = (bucket?.timeoutMessage?.doc_count ?? 0) > 0; + + const actionExecutionOutcomes = bucket?.actionExecution?.actionOutcomes?.buckets ?? []; + const actionExecutionSuccess = + actionExecutionOutcomes.find((subBucket) => subBucket?.key === 'success')?.doc_count ?? 0; + const actionExecutionError = + actionExecutionOutcomes.find((subBucket) => subBucket?.key === 'failure')?.doc_count ?? 0; + + return { + id: bucket?.key ?? '', + timestamp: bucket?.ruleExecution?.executeStartTime.value_as_string ?? '', + duration_ms: durationUs / Millis2Nanos, + status: bucket?.ruleExecution?.outcomeAndMessage?.hits?.hits[0]?._source?.event?.outcome, + message: bucket?.ruleExecution?.outcomeAndMessage?.hits?.hits[0]?._source?.message, + num_active_alerts: bucket?.alertCounts?.buckets?.activeAlerts?.doc_count ?? 0, + num_new_alerts: bucket?.alertCounts?.buckets?.newAlerts?.doc_count ?? 0, + num_recovered_alerts: bucket?.alertCounts?.buckets?.recoveredAlerts?.doc_count ?? 0, + num_triggered_actions: bucket?.ruleExecution?.numTriggeredActions?.value ?? 0, + num_succeeded_actions: actionExecutionSuccess, + num_errored_actions: actionExecutionError, + total_search_duration_ms: bucket?.ruleExecution?.totalSearchDuration?.value ?? 0, + es_search_duration_ms: bucket?.ruleExecution?.esSearchDuration?.value ?? 0, + schedule_delay_ms: scheduleDelayUs / Millis2Nanos, + timed_out: timedOut, + }; +} + +export function formatExecutionLogResult( + results: AggregateEventsBySavedObjectResult +): IExecutionLogResult { + const { aggregations } = results; + + if (!aggregations) { + return { + total: 0, + data: [], + }; + } + + const total = (aggregations.executionUuidCardinality as estypes.AggregationsCardinalityAggregate) + .value; + const buckets = (aggregations.executionUuid as ExecutionUuidAggResult).buckets; + + return { + total, + data: buckets.map((bucket: IExecutionUuidAggBucket) => formatExecutionLogAggBucket(bucket)), + }; +} + +export function getNumExecutions(dateStart: Date, dateEnd: Date, ruleSchedule: string) { + const durationInMillis = dateEnd.getTime() - dateStart.getTime(); + const scheduleMillis = parseDuration(ruleSchedule); + + const numExecutions = Math.ceil(durationInMillis / scheduleMillis); + + return Math.min(numExecutions < 0 ? 0 : numExecutions, DEFAULT_MAX_BUCKETS_LIMIT); +} + +export function formatSortForBucketSort(sort: estypes.Sort) { + return (sort as estypes.SortCombinations[]).map((s) => + Object.keys(s).reduce( + (acc, curr) => ({ ...acc, [ExecutionLogSortFields[curr]]: get(s, curr) }), + {} + ) + ); +} + +export function formatSortForTermSort(sort: estypes.Sort) { + return (sort as estypes.SortCombinations[]).map((s) => + Object.keys(s).reduce( + (acc, curr) => ({ ...acc, [ExecutionLogSortFields[curr]]: get(s, `${curr}.order`) }), + {} + ) + ); +} diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts new file mode 100644 index 0000000000000..e359e9c52dda0 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { getRuleExecutionLogRoute } from './get_rule_execution_log'; +import { httpServiceMock } from 'src/core/server/mocks'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { SavedObjectsErrorHelpers } from 'src/core/server'; +import { rulesClientMock } from '../rules_client.mock'; +import { IExecutionLogResult } from '../lib/get_execution_log_aggregation'; + +const rulesClient = rulesClientMock.create(); +jest.mock('../lib/license_api_access.ts', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('getRuleExecutionLogRoute', () => { + const dateString = new Date().toISOString(); + const mockedExecutionLog: IExecutionLogResult = { + total: 374, + data: [ + { + id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + timestamp: '2022-03-07T15:38:32.617Z', + duration_ms: 1056, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 0, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3008, + }, + ], + }; + + it('gets rule execution log', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getRuleExecutionLogRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rule/{id}/_execution_log"`); + + rulesClient.getExecutionLogForRule.mockResolvedValue(mockedExecutionLog); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + query: { + date_start: dateString, + per_page: 10, + page: 1, + sort: [{ timestamp: { order: 'desc' } }], + }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesClient.getExecutionLogForRule).toHaveBeenCalledTimes(1); + expect(rulesClient.getExecutionLogForRule.mock.calls[0]).toEqual([ + { + dateStart: dateString, + id: '1', + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }, + ]); + + expect(res.ok).toHaveBeenCalled(); + }); + + it('returns NOT-FOUND when rule is not found', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getRuleExecutionLogRoute(router, licenseState); + + const [, handler] = router.get.mock.calls[0]; + + rulesClient.getExecutionLogForRule = jest + .fn() + .mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError('alert', '1')); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + params: { + id: '1', + }, + query: {}, + }, + ['notFound'] + ); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot( + `[Error: Saved object [alert/1] not found]` + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts new file mode 100644 index 0000000000000..845c14ecf0ea4 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts @@ -0,0 +1,77 @@ +/* + * 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 'kibana/server'; +import { schema } from '@kbn/config-schema'; +import { ILicenseState } from '../lib'; +import { GetExecutionLogByIdParams } from '../rules_client'; +import { RewriteRequestCase, verifyAccessAndContext } from './lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types'; + +const paramSchema = schema.object({ + id: schema.string(), +}); + +const sortOrderSchema = schema.oneOf([schema.literal('asc'), schema.literal('desc')]); + +const sortFieldSchema = schema.oneOf([ + schema.object({ timestamp: schema.object({ order: sortOrderSchema }) }), + schema.object({ execution_duration: schema.object({ order: sortOrderSchema }) }), + schema.object({ total_search_duration: schema.object({ order: sortOrderSchema }) }), + schema.object({ es_search_duration: schema.object({ order: sortOrderSchema }) }), + schema.object({ schedule_delay: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_triggered_actions: schema.object({ order: sortOrderSchema }) }), +]); + +const sortFieldsSchema = schema.arrayOf(sortFieldSchema, { + defaultValue: [{ timestamp: { order: 'desc' } }], +}); + +const querySchema = schema.object({ + date_start: schema.string(), + date_end: schema.maybe(schema.string()), + filter: schema.maybe(schema.string()), + per_page: schema.number({ defaultValue: 10, min: 1 }), + page: schema.number({ defaultValue: 1, min: 1 }), + sort: sortFieldsSchema, +}); + +const rewriteReq: RewriteRequestCase = ({ + date_start: dateStart, + date_end: dateEnd, + per_page: perPage, + ...rest +}) => ({ + ...rest, + dateStart, + dateEnd, + perPage, +}); + +export const getRuleExecutionLogRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rule/{id}/_execution_log`, + validate: { + params: paramSchema, + query: querySchema, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = context.alerting.getRulesClient(); + const { id } = req.params; + return res.ok({ + body: await rulesClient.getExecutionLogForRule(rewriteReq({ id, ...req.query })), + }); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index 1cb58fd6d0657..ed1a9583cc75c 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -20,6 +20,7 @@ import { disableRuleRoute } from './disable_rule'; import { enableRuleRoute } from './enable_rule'; import { findRulesRoute, findInternalRulesRoute } from './find_rules'; import { getRuleAlertSummaryRoute } from './get_rule_alert_summary'; +import { getRuleExecutionLogRoute } from './get_rule_execution_log'; import { getRuleStateRoute } from './get_rule_state'; import { healthRoute } from './health'; import { resolveRuleRoute } from './resolve_rule'; @@ -54,6 +55,7 @@ export function defineRoutes(opts: RouteOptions) { findRulesRoute(router, licenseState, usageCounter); findInternalRulesRoute(router, licenseState, usageCounter); getRuleAlertSummaryRoute(router, licenseState); + getRuleExecutionLogRoute(router, licenseState); getRuleStateRoute(router, licenseState); healthRoute(router, licenseState, encryptedSavedObjects); ruleTypesRoute(router, licenseState); diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts index 2a7fb7177ce4c..de1de6a8e3cbc 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -30,6 +30,7 @@ const createRulesClientMock = () => { unmuteInstance: jest.fn(), listAlertTypes: jest.fn(), getAlertSummary: jest.fn(), + getExecutionLogForRule: jest.fn(), getSpaceId: jest.fn(), snooze: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/audit_events.ts index 96d6b9a5d17ef..65be7fc739ca2 100644 --- a/x-pack/plugins/alerting/server/rules_client/audit_events.ts +++ b/x-pack/plugins/alerting/server/rules_client/audit_events.ts @@ -23,6 +23,7 @@ export enum RuleAuditAction { MUTE_ALERT = 'rule_alert_mute', UNMUTE_ALERT = 'rule_alert_unmute', AGGREGATE = 'rule_aggregate', + GET_EXECUTION_LOG = 'rule_get_execution_log', SNOOZE = 'rule_snooze', } @@ -43,6 +44,11 @@ const eventVerbs: Record = { rule_alert_mute: ['mute alert of', 'muting alert of', 'muted alert of'], rule_alert_unmute: ['unmute alert of', 'unmuting alert of', 'unmuted alert of'], rule_aggregate: ['access', 'accessing', 'accessed'], + rule_get_execution_log: [ + 'access execution log for', + 'accessing execution log for', + 'accessed execution log for', + ], rule_snooze: ['snooze', 'snoozing', 'snoozed'], }; @@ -61,6 +67,7 @@ const eventTypes: Record = { rule_alert_mute: 'change', rule_alert_unmute: 'change', rule_aggregate: 'access', + rule_get_execution_log: 'access', rule_snooze: 'change', }; 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 4208c0d76d5ff..e396b4fd94943 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -84,6 +84,11 @@ import { getModifiedSearch, modifyFilterKueryNode, } from './lib/mapped_params_utils'; +import { + formatExecutionLogResult, + getExecutionLogAggregation, + IExecutionLogResult, +} from '../lib/get_execution_log_aggregation'; import { validateSnoozeDate } from '../lib/validate_snooze_date'; import { RuleMutedError } from '../lib/errors/rule_muted'; @@ -235,6 +240,16 @@ export interface GetAlertSummaryParams { numberOfExecutions?: number; } +export interface GetExecutionLogByIdParams { + id: string; + dateStart: string; + dateEnd?: string; + filter?: string; + page: number; + perPage: number; + sort: estypes.Sort; +} + // NOTE: Changing this prefix will require a migration to update the prefix in all existing `rule` saved objects const extractedSavedObjectParamReferenceNamePrefix = 'param:'; @@ -639,6 +654,70 @@ export class RulesClient { }); } + public async getExecutionLogForRule({ + id, + dateStart, + dateEnd, + filter, + page, + perPage, + sort, + }: GetExecutionLogByIdParams): Promise { + this.logger.debug(`getExecutionLogForRule(): getting execution log for rule ${id}`); + const rule = (await this.get({ id, includeLegacyId: true })) as SanitizedRuleWithLegacyId; + + try { + // Make sure user has access to this rule + await this.authorization.ensureAuthorized({ + ruleTypeId: rule.alertTypeId, + consumer: rule.consumer, + operation: ReadOperations.GetExecutionLog, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.GET_EXECUTION_LOG, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.GET_EXECUTION_LOG, + savedObject: { type: 'alert', id }, + }) + ); + + // default duration of instance summary is 60 * rule interval + const dateNow = new Date(); + const parsedDateStart = parseDate(dateStart, 'dateStart', dateNow); + const parsedDateEnd = parseDate(dateEnd, 'dateEnd', dateNow); + + const eventLogClient = await this.getEventLogClient(); + + const results = await eventLogClient.aggregateEventsBySavedObjectIds( + 'alert', + [id], + { + start: parsedDateStart.toISOString(), + end: parsedDateEnd.toISOString(), + filter, + aggs: getExecutionLogAggregation({ + page, + perPage, + sort, + }), + }, + rule.legacyId !== null ? [rule.legacyId] : undefined + ); + + return formatExecutionLogResult(results); + } + public async find({ options: { fields, ...options } = {}, excludeFromPublicApi = false, 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 new file mode 100644 index 0000000000000..a55a3e57428bb --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -0,0 +1,613 @@ +/* + * 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 type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { RulesClient, ConstructorOptions } from '../rules_client'; +import { savedObjectsClientMock, loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; +import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; +import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/server/mocks'; +import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; +import { AlertingAuthorization } from '../../authorization/alerting_authorization'; +import { ActionsAuthorization } from '../../../../actions/server'; +import { eventLogClientMock } from '../../../../event_log/server/mocks'; +import { SavedObject } from 'kibana/server'; +import { RawRule } from '../../types'; +import { auditLoggerMock } from '../../../../security/server/audit/mocks'; +import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; +import { getExecutionLogAggregation } from '../../lib/get_execution_log_aggregation'; + +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const eventLogClient = eventLogClientMock.create(); + +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const kibanaVersion = 'v7.10.0'; +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + minimumScheduleInterval: '1m', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, +}; + +beforeEach(() => { + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry, eventLogClient); + (auditLogger.log as jest.Mock).mockClear(); +}); + +setGlobalDate(); + +const RuleIntervalSeconds = 1; + +const BaseRuleSavedObject: SavedObject = { + id: '1', + type: 'alert', + attributes: { + enabled: true, + name: 'rule-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: '123', + consumer: 'rule-consumer', + legacyId: null, + schedule: { interval: `${RuleIntervalSeconds}s` }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: mockedDateString, + updatedAt: mockedDateString, + apiKey: null, + apiKeyOwner: null, + throttle: null, + notifyWhen: null, + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: '2020-08-20T19:23:38Z', + error: null, + warning: null, + }, + }, + references: [], +}; + +const aggregateResults = { + aggregations: { + executionUuid: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + doc_count: 27, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 0, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'S4wIZX8B8TGQpG7XQZns', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.126e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.056e9, + }, + executeStartTime: { + value: 1.646667512617e12, + value_as_string: '2022-03-07T15:38:32.617Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + { + key: '41b2755e-765a-4044-9745-b03875d5e79a', + doc_count: 32, + timeoutMessage: { + meta: {}, + doc_count: 0, + }, + alertCounts: { + meta: {}, + buckets: { + activeAlerts: { + doc_count: 5, + }, + newAlerts: { + doc_count: 5, + }, + recoveredAlerts: { + doc_count: 5, + }, + }, + }, + ruleExecution: { + meta: {}, + doc_count: 1, + numTriggeredActions: { + value: 5.0, + }, + outcomeAndMessage: { + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 1.0, + hits: [ + { + _index: '.kibana-event-log-8.2.0-000001', + _id: 'a4wIZX8B8TGQpG7Xwpnz', + _score: 1.0, + _source: { + event: { + outcome: 'success', + }, + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + }, + }, + ], + }, + }, + scheduleDelay: { + value: 3.345e9, + }, + totalSearchDuration: { + value: 0.0, + }, + esSearchDuration: { + value: 0.0, + }, + executionDuration: { + value: 1.165e9, + }, + executeStartTime: { + value: 1.646667545604e12, + value_as_string: '2022-03-07T15:39:05.604Z', + }, + }, + actionExecution: { + meta: {}, + doc_count: 5, + actionOutcomes: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'success', + doc_count: 5, + }, + ], + }, + }, + }, + ], + }, + executionUuidCardinality: { + value: 374, + }, + }, +}; + +function getRuleSavedObject(attributes: Partial = {}): SavedObject { + return { + ...BaseRuleSavedObject, + attributes: { ...BaseRuleSavedObject.attributes, ...attributes }, + }; +} + +function getExecutionLogByIdParams(overwrites = {}) { + return { + id: '1', + dateStart: new Date(Date.now() - 3600000).toISOString(), + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }] as estypes.Sort, + ...overwrites, + }; +} +describe('getExecutionLogForRule()', () => { + let rulesClient: RulesClient; + + beforeEach(() => { + rulesClient = new RulesClient(rulesClientParams); + }); + + test('runs as expected with some event log aggregation data', async () => { + const ruleSO = getRuleSavedObject({}); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + const result = await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + expect(result).toEqual({ + total: 374, + data: [ + { + id: '6705da7d-2635-499d-a6a8-1aee1ae1eac9', + timestamp: '2022-03-07T15:38:32.617Z', + duration_ms: 1056, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 0, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3126, + }, + { + id: '41b2755e-765a-4044-9745-b03875d5e79a', + timestamp: '2022-03-07T15:39:05.604Z', + duration_ms: 1165, + status: 'success', + message: + "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + num_active_alerts: 5, + num_new_alerts: 5, + num_recovered_alerts: 5, + num_triggered_actions: 5, + num_succeeded_actions: 5, + num_errored_actions: 0, + total_search_duration_ms: 0, + es_search_duration_ms: 0, + timed_out: false, + schedule_delay_ms: 3345, + }, + ], + }); + }); + + // Further tests don't check the result of `getExecutionLogForRule()`, as the result + // is just the result from the `formatExecutionLogResult()`, which itself + // has a complete set of tests. + + test('calls saved objects and event log client with default params', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + end: mockedDateString, + start: '2019-02-12T20:01:22.479Z', + }, + undefined, + ]); + }); + + test('calls event log client with legacy ids param', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce( + getRuleSavedObject({ legacyId: '99999' }) + ); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + end: mockedDateString, + start: '2019-02-12T20:01:22.479Z', + }, + ['99999'], + ]); + }); + + test('calls event log client with end date if specified', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule( + getExecutionLogByIdParams({ dateEnd: new Date(Date.now() - 2700000).toISOString() }) + ); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + end: '2019-02-12T20:16:22.479Z', + start: '2019-02-12T20:01:22.479Z', + }, + undefined, + ]); + }); + + test('calls event log client with filter if specified', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + await rulesClient.getExecutionLogForRule( + getExecutionLogByIdParams({ filter: 'event.outcome: success' }) + ); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds).toHaveBeenCalledTimes(1); + expect(eventLogClient.aggregateEventsBySavedObjectIds.mock.calls[0]).toEqual([ + 'alert', + ['1'], + { + aggs: getExecutionLogAggregation({ + page: 1, + perPage: 10, + sort: [{ timestamp: { order: 'desc' } }], + }), + filter: 'event.outcome: success', + end: mockedDateString, + start: '2019-02-12T20:01:22.479Z', + }, + undefined, + ]); + }); + + test('invalid start date throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + const dateStart = 'ain"t no way this will get parsed as a date'; + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ dateStart })) + ).rejects.toMatchInlineSnapshot( + `[Error: Invalid date for parameter dateStart: "ain"t no way this will get parsed as a date"]` + ); + }); + + test('invalid end date throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + const dateEnd = 'ain"t no way this will get parsed as a date'; + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ dateEnd })) + ).rejects.toMatchInlineSnapshot( + `[Error: Invalid date for parameter dateEnd: "ain"t no way this will get parsed as a date"]` + ); + }); + + test('invalid page value throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ page: -3 })) + ).rejects.toMatchInlineSnapshot(`[Error: Invalid page field "-3" - must be greater than 0]`); + }); + + test('invalid perPage value throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams({ perPage: -3 })) + ).rejects.toMatchInlineSnapshot(`[Error: Invalid perPage field "-3" - must be greater than 0]`); + }); + + test('invalid sort value throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule( + getExecutionLogByIdParams({ sort: [{ foo: { order: 'desc' } }] }) + ) + ).rejects.toMatchInlineSnapshot( + `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions]]` + ); + }); + + test('throws error when saved object get throws an error', async () => { + unsecuredSavedObjectsClient.get.mockRejectedValueOnce(new Error('OMG!')); + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: OMG!]`); + }); + + test('throws error when eventLog.aggregateEventsBySavedObjectIds throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getRuleSavedObject()); + eventLogClient.aggregateEventsBySavedObjectIds.mockRejectedValueOnce(new Error('OMG 2!')); + + expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: OMG 2!]`); + }); + + describe('authorization', () => { + beforeEach(() => { + const ruleSO = getRuleSavedObject({}); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO); + }); + + test('ensures user is authorised to get this type of alert under the consumer', async () => { + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'rule-consumer', + operation: 'get', + ruleTypeId: '123', + }); + }); + + test('throws when user is not authorised to get this type of alert', async () => { + authorization.ensureAuthorized.mockRejectedValueOnce( + new Error(`Unauthorized to get a "myType" alert for "myApp"`) + ); + + await expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to get a "myType" alert for "myApp"]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'rule-consumer', + operation: 'get', + ruleTypeId: '123', + }); + }); + }); + + describe('auditLogger', () => { + beforeEach(() => { + const ruleSO = getRuleSavedObject({}); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(ruleSO); + }); + + test('logs audit event when getting a rule execution log', async () => { + eventLogClient.aggregateEventsBySavedObjectIds.mockResolvedValueOnce(aggregateResults); + await rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_get_execution_log', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to get a rule', async () => { + // first call occurs during rule SO get + authorization.ensureAuthorized.mockResolvedValueOnce(); + authorization.ensureAuthorized.mockRejectedValueOnce(new Error('Unauthorized')); + + await expect( + rulesClient.getExecutionLogForRule(getExecutionLogByIdParams()) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized]`); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'rule_get_execution_log', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index 861f6900fda58..a3b4b76980cd3 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -87,6 +87,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", ] `); @@ -169,6 +170,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", @@ -211,6 +213,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -304,6 +307,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -357,6 +361,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -371,6 +376,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", ] `); @@ -456,6 +462,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/create", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/delete", @@ -470,6 +477,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getAlertSummary", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getExecutionLog", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/find", "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/get", "alerting:1.0.0-zeta1:another-alert-type/my-feature/alert/find", diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts index f536959a910cd..4d2cc97f75d89 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -16,7 +16,7 @@ enum AlertingEntity { } const readOperations: Record = { - rule: ['get', 'getRuleState', 'getAlertSummary', 'find'], + rule: ['get', 'getRuleState', 'getAlertSummary', 'getExecutionLog', 'find'], alert: ['get', 'find'], }; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts new file mode 100644 index 0000000000000..55d4a72643c86 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_execution_log.ts @@ -0,0 +1,456 @@ +/* + * 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, + getTestRuleData, + getEventLog, + ESTestIndexTool, +} from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createGetExecutionLogTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + const es = getService('es'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + + const dateStart = new Date(Date.now() - 600000).toISOString(); + + describe('getExecutionLog', () => { + const objectRemover = new ObjectRemover(supertest); + + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + + afterEach(() => objectRemover.removeAll()); + + it(`handles non-existent rule`, async () => { + await supertest + .get( + `${getUrlPrefix( + Spaces.space1.id + )}/internal/alerting/rule/1/_execution_log?date_start=${dateStart}` + ) + .expect(404, { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }); + }); + + it('gets execution log for rule with executions', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '15s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 2 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(2); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(2); + + let previousTimestamp: string | null = null; + for (const log of execLogs) { + if (previousTimestamp) { + // default sort is `desc` by timestamp + expect(Date.parse(log.timestamp)).to.be.lessThan(Date.parse(previousTimestamp)); + } + previousTimestamp = log.timstamp; + expect(Date.parse(log.timestamp)).to.be.greaterThan(Date.parse(dateStart)); + expect(Date.parse(log.timestamp)).to.be.lessThan(Date.parse(new Date().toISOString())); + + expect(log.duration_ms).to.be.greaterThan(0); + expect(log.schedule_delay_ms).to.be.greaterThan(0); + expect(log.status).to.equal('success'); + expect(log.timed_out).to.equal(false); + + // no-op rule doesn't generate alerts + expect(log.num_active_alerts).to.equal(0); + expect(log.num_new_alerts).to.equal(0); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(0); + expect(log.num_succeeded_actions).to.equal(0); + expect(log.num_errored_actions).to.equal(0); + + // no-op rule doesn't query ES + expect(log.total_search_duration_ms).to.equal(0); + expect(log.es_search_duration_ms).to.equal(0); + } + }); + + it('gets execution log for rule with no executions', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '15s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(0); + expect(response.body.data).to.eql([]); + }); + + it('gets execution log for rule that performs ES searches', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.multipleSearches', + params: { + numSearches: 2, + delay: `2s`, + }, + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.duration_ms).to.be.greaterThan(0); + expect(log.schedule_delay_ms).to.be.greaterThan(0); + expect(log.status).to.equal('success'); + expect(log.timed_out).to.equal(false); + + // no-op rule doesn't generate alerts + expect(log.num_active_alerts).to.equal(0); + expect(log.num_new_alerts).to.equal(0); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(0); + expect(log.num_succeeded_actions).to.equal(0); + expect(log.num_errored_actions).to.equal(0); + + // rule executes 2 searches with delay of 2 seconds each + // setting compare threshold lower to avoid flakiness + expect(log.total_search_duration_ms).to.be.greaterThan(2000); + expect(log.es_search_duration_ms).to.be.greaterThan(2000); + } + }); + + it('gets execution log for rule that errors', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.throw', + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('failure'); + expect(log.timed_out).to.equal(false); + } + }); + + it('gets execution log for rule that times out', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternLongRunning', + params: { + pattern: [true, true, true, true], + }, + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('success'); + expect(log.timed_out).to.equal(true); + } + }); + + it('gets execution log for rule that triggers actions', async () => { + const { body: createdConnector } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'noop connector', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + objectRemover.add(Spaces.space1.id, createdConnector.id, 'action', 'actions'); + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.cumulative-firing', + actions: [ + { + id: createdConnector.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + await waitForEvents(createdRule.id, 'actions', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('success'); + + expect(log.num_active_alerts).to.equal(1); + expect(log.num_new_alerts).to.equal(1); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(1); + expect(log.num_succeeded_actions).to.equal(1); + expect(log.num_errored_actions).to.equal(0); + } + }); + + it('gets execution log for rule that has failed actions', async () => { + const { body: createdConnector } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'connector that throws', + connector_type_id: 'test.throw', + config: {}, + secrets: {}, + }) + .expect(200); + objectRemover.add(Spaces.space1.id, createdConnector.id, 'action', 'actions'); + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.cumulative-firing', + actions: [ + { + id: createdConnector.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 1 }]])); + await waitForEvents(createdRule.id, 'actions', new Map([['execute', { gte: 1 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(1); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(1); + + for (const log of execLogs) { + expect(log.status).to.equal('success'); + + expect(log.num_active_alerts).to.equal(1); + expect(log.num_new_alerts).to.equal(1); + expect(log.num_recovered_alerts).to.equal(0); + expect(log.num_triggered_actions).to.equal(1); + expect(log.num_succeeded_actions).to.equal(0); + expect(log.num_errored_actions).to.equal(1); + } + }); + + it('handles date_end if specified', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '10s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 2 }]])); + + // set the date end to date start - should filter out all execution logs + const earlierDateStart = new Date(new Date(dateStart).getTime() - 900000).toISOString(); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${earlierDateStart}&date_end=${dateStart}` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(0); + expect(response.body.data.length).to.eql(0); + }); + + it('handles sort query parameter', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '5s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 3 }]])); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=${dateStart}&sort=[{"timestamp":{"order":"asc"}}]` + ); + + expect(response.status).to.eql(200); + + expect(response.body.total).to.eql(3); + + const execLogs = response.body.data; + expect(execLogs.length).to.eql(3); + + let previousTimestamp: string | null = null; + for (const log of execLogs) { + if (previousTimestamp) { + // sorting by `asc` timestamp + expect(Date.parse(log.timestamp)).to.be.greaterThan(Date.parse(previousTimestamp)); + } + previousTimestamp = log.timstamp; + } + }); + + it(`handles invalid date_start`, async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ schedule: { interval: '10s' } })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + await waitForEvents(createdRule.id, 'alerting', new Map([['execute', { gte: 2 }]])); + await supertest + .get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ + createdRule.id + }/_execution_log?date_start=X0X0-08-08T08:08:08.008Z` + ) + .expect(400, { + statusCode: 400, + error: 'Bad Request', + message: 'Invalid date for parameter dateStart: "X0X0-08-08T08:08:08.008Z"', + }); + }); + }); + + async function waitForEvents( + id: string, + provider: string, + actions: Map< + string, + { + gte: number; + } + > + ) { + await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider, + actions, + }); + }); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index 242c6ffcba10f..14c8268ce80e0 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -23,6 +23,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./get_alert_state')); loadTestFile(require.resolve('./get_alert_summary')); + loadTestFile(require.resolve('./get_execution_log')); loadTestFile(require.resolve('./rule_types')); loadTestFile(require.resolve('./event_log')); loadTestFile(require.resolve('./execution_status'));