From ee1552f10d8fec15e0f3eceeee809329c5250bf0 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 25 Apr 2024 15:36:01 -0400 Subject: [PATCH] [Response Ops][Alerting] Backfill Rule Runs (#177622) This is the feature branch that contains the following commits. Each individual PR contains a summary and verification instructions. - [Schedule backfill API](https://github.com/elastic/kibana/pull/176185) - [Backfill task runner](https://github.com/elastic/kibana/pull/177640) - [Get/Find/Delete backfill API](https://github.com/elastic/kibana/pull/179975) - [API key invalidation update](https://github.com/elastic/kibana/pull/180749) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- docs/user/security/audit-logging.asciidoc | 20 + .../src/constants.ts | 1 + .../src/field_maps/alert_field_map.ts | 6 + .../src/schemas/generated/alert_schema.ts | 1 + .../src/schemas/generated/security_schema.ts | 1 + .../current_fields.json | 9 + .../current_mappings.json | 27 + .../src/default_alerts_as_data.ts | 5 + .../check_registered_types.test.ts | 1 + .../group3/type_registrations.test.ts | 1 + .../group5/dot_kibana_split.test.ts | 1 + x-pack/plugins/alerting/README.md | 4 + .../field_maps/mapping_from_field_map.test.ts | 3 + .../common/constants/ad_hoc_run_status.ts | 15 + .../alerting/common/constants/backfill.ts | 8 + .../alerting/common/constants/index.ts | 11 + .../routes/backfill/apis/delete/index.ts | 12 + .../backfill/apis/delete/schemas/latest.ts | 8 + .../routes/backfill/apis/delete/schemas/v1.ts | 11 + .../backfill/apis/delete/types/latest.ts | 8 + .../routes/backfill/apis/delete/types/v1.ts | 11 + .../common/routes/backfill/apis/find/index.ts | 23 + .../backfill/apis/find/schemas/latest.ts | 8 + .../routes/backfill/apis/find/schemas/v1.ts | 43 + .../routes/backfill/apis/find/types/latest.ts | 8 + .../routes/backfill/apis/find/types/v1.ts | 16 + .../common/routes/backfill/apis/get/index.ts | 23 + .../backfill/apis/get/schemas/latest.ts | 8 + .../routes/backfill/apis/get/schemas/v1.ts | 14 + .../routes/backfill/apis/get/types/latest.ts | 8 + .../routes/backfill/apis/get/types/v1.ts | 16 + .../routes/backfill/apis/schedule/index.ts | 23 + .../backfill/apis/schedule/schemas/latest.ts | 8 + .../backfill/apis/schedule/schemas/v1.ts | 44 + .../backfill/apis/schedule/types/latest.ts | 8 + .../routes/backfill/apis/schedule/types/v1.ts | 16 + .../common/routes/backfill/response/index.ts | 18 + .../backfill/response/schemas/latest.ts | 8 + .../routes/backfill/response/schemas/v1.ts | 59 + .../routes/backfill/response/types/latest.ts | 8 + .../routes/backfill/response/types/v1.ts | 12 + .../alerts_client/alerts_client.test.ts | 119 ++ .../server/alerts_client/alerts_client.ts | 8 + .../alerts_client/lib/build_new_alert.test.ts | 41 + .../alerts_client/lib/build_new_alert.ts | 4 + .../lib/build_ongoing_alert.test.ts | 66 + .../alerts_client/lib/build_ongoing_alert.ts | 4 + .../lib/build_recovered_alert.test.ts | 67 + .../lib/build_recovered_alert.ts | 4 + .../lib/build_updated_recovered_alert.test.ts | 52 + .../lib/build_updated_recovered_alert.ts | 4 + .../lib/initialize_alerts_client.test.ts | 84 +- .../lib/initialize_alerts_client.ts | 17 +- .../alerting/server/alerts_client/types.ts | 1 + .../methods/delete/delete_backfill.test.ts | 278 +++ .../methods/delete/delete_backfill.ts | 96 + .../backfill/methods/delete/index.ts | 8 + .../methods/find/find_backfill.test.ts | 785 ++++++++ .../backfill/methods/find/find_backfill.ts | 127 ++ .../backfill/methods/find/index.ts | 8 + .../schemas/find_backfill_query_schema.ts | 36 + .../schemas/find_backfill_result_schema.ts | 16 + .../backfill/methods/find/schemas/index.ts | 9 + .../backfill/methods/find/types/index.ts | 12 + .../backfill/methods/get/get_backfill.test.ts | 236 +++ .../backfill/methods/get/get_backfill.ts | 78 + .../application/backfill/methods/get/index.ts | 8 + .../backfill/methods/schedule/index.ts | 8 + .../schedule/schedule_backfill.test.ts | 604 ++++++ .../methods/schedule/schedule_backfill.ts | 164 ++ .../methods/schedule/schemas/index.ts | 16 + .../schedule_backfill_params_schema.ts | 42 + .../schedule_backfill_result_schema.ts | 22 + .../backfill/methods/schedule/types/index.ts | 21 + .../backfill/result/schemas/index.ts | 52 + .../backfill/result/types/index.ts | 12 + .../application/backfill/transforms/index.ts | 9 + ...form_ad_hoc_run_to_backfill_result.test.ts | 251 +++ ...transform_ad_hoc_run_to_backfill_result.ts | 60 + ...sform_backfill_param_to_ad_hoc_run.test.ts | 154 ++ .../transform_backfill_param_to_ad_hoc_run.ts | 49 + .../methods/aggregate/aggregate_rules.test.ts | 2 + .../bulk_delete/bulk_delete_rules.test.ts | 2 + .../bulk_disable/bulk_disable_rules.test.ts | 2 + .../methods/bulk_edit/bulk_edit_rules.test.ts | 2 + .../bulk_untrack/bulk_untrack_alerts.test.ts | 2 + .../rule/methods/create/create_rule.test.ts | 2 + .../get_schedule_frequency.test.ts | 2 + .../rule/methods/resolve/resolve.test.ts | 3 + .../rule/methods/tags/get_rule_tags.test.ts | 2 + .../rule/methods/update/update_rule.test.ts | 2 + .../authorization/alerting_authorization.ts | 4 + .../backfill_client/backfill_client.mock.ts | 17 + .../backfill_client/backfill_client.test.ts | 858 ++++++++ .../server/backfill_client/backfill_client.ts | 279 +++ .../lib/calculate_schedule.test.ts | 120 ++ .../backfill_client/lib/calculate_schedule.ts | 30 + .../lib/create_backfill_error.ts | 12 + .../server/backfill_client/lib/index.ts | 9 + .../data/ad_hoc_run/types/ad_hoc_run.ts | 75 + .../server/data/ad_hoc_run/types/index.ts | 8 + x-pack/plugins/alerting/server/index.ts | 2 +- .../alert_as_data_fields.test.ts.snap | 40 + ...ulk_mark_api_keys_for_invalidation.test.ts | 5 +- .../bulk_mark_api_keys_for_invalidation.ts | 3 +- .../invalidate_pending_api_keys/task.test.ts | 1120 +++++++++++ .../invalidate_pending_api_keys/task.ts | 250 ++- .../alerting_event_logger.mock.ts | 3 +- .../alerting_event_logger.test.ts | 1722 ++++++++++++----- .../alerting_event_logger.ts | 485 +++-- .../create_alert_event_log_record_object.ts | 22 +- .../server/lib/get_time_range.test.ts | 90 +- .../alerting/server/lib/get_time_range.ts | 44 +- x-pack/plugins/alerting/server/plugin.ts | 33 +- .../apis/delete/delete_backfill_route.test.ts | 70 + .../apis/delete/delete_backfill_route.ts | 37 + .../apis/find/find_backfill_route.test.ts | 145 ++ .../backfill/apis/find/find_backfill_route.ts | 42 + .../backfill/apis/find/transforms/index.ts | 12 + .../transforms/transform_request/latest.ts | 8 + .../find/transforms/transform_request/v1.ts | 28 + .../transforms/transform_response/latest.ts | 8 + .../find/transforms/transform_response/v1.ts | 22 + .../apis/get/get_backfill_route.test.ts | 104 + .../backfill/apis/get/get_backfill_route.ts | 42 + .../schedule/schedule_backfill_route.test.ts | 153 ++ .../apis/schedule/schedule_backfill_route.ts | 42 + .../apis/schedule/transforms/index.ts | 12 + .../transforms/transform_request/latest.ts | 8 + .../transforms/transform_request/v1.ts | 13 + .../transforms/transform_response/latest.ts | 8 + .../transforms/transform_response/v1.ts | 27 + .../routes/backfill/transforms/index.ts | 9 + .../latest.ts | 8 + .../v1.test.ts | 72 + .../v1.ts | 43 + .../plugins/alerting/server/routes/index.ts | 12 + .../alerting/server/rules_client.mock.ts | 4 + .../rules_client/common/audit_events.test.ts | 106 +- .../rules_client/common/audit_events.ts | 79 +- .../lib/add_generated_action_values.test.ts | 2 + .../lib/create_new_api_key_set.test.ts | 2 + .../server/rules_client/rules_client.ts | 15 + .../rules_client/tests/bulk_enable.test.ts | 2 + .../tests/clear_expired_snoozes.test.ts | 2 + .../server/rules_client/tests/clone.test.ts | 3 + .../server/rules_client/tests/delete.test.ts | 2 + .../server/rules_client/tests/disable.test.ts | 2 + .../server/rules_client/tests/enable.test.ts | 12 +- .../server/rules_client/tests/find.test.ts | 2 + .../server/rules_client/tests/get.test.ts | 2 + .../tests/get_action_error_log.test.ts | 2 + .../tests/get_alert_state.test.ts | 2 + .../tests/get_alert_summary.test.ts | 2 + .../tests/get_execution_log.test.ts | 2 + .../tests/list_rule_types.test.ts | 2 + .../rules_client/tests/mute_all.test.ts | 2 + .../rules_client/tests/mute_instance.test.ts | 2 + .../server/rules_client/tests/resolve.test.ts | 2 + .../rules_client/tests/run_soon.test.ts | 2 + .../rules_client/tests/unmute_all.test.ts | 2 + .../tests/unmute_instance.test.ts | 2 + .../rules_client/tests/update_api_key.test.ts | 2 + .../alerting/server/rules_client/types.ts | 2 + .../rules_client_conflict_retries.test.ts | 2 + .../server/rules_client_factory.test.ts | 23 +- .../alerting/server/rules_client_factory.ts | 17 +- .../ad_hoc_run_params_model_versions.ts | 51 + .../alerting/server/saved_objects/index.ts | 60 +- .../schemas/raw_ad_hoc_run_params/index.ts | 8 + .../schemas/raw_ad_hoc_run_params/v1.ts | 55 + .../task_runner/ad_hoc_task_runner.test.ts | 1518 +++++++++++++++ .../server/task_runner/ad_hoc_task_runner.ts | 597 ++++++ .../ad_hoc_task_running_handler.test.ts | 112 ++ .../ad_hoc_task_running_handler.ts | 67 + .../alerting/server/task_runner/lib/index.ts | 9 + .../lib/partially_update_ad_hoc_run.test.ts | 159 ++ .../lib/partially_update_ad_hoc_run.ts | 67 + .../lib/process_run_result.test.ts | 169 ++ .../task_runner/lib/process_run_result.ts | 92 + ...r.test.ts => rule_running_handler.test.ts} | 8 +- ...ing_handler.ts => rule_running_handler.ts} | 2 +- .../task_runner/rule_type_runner.test.ts | 313 ++- .../server/task_runner/rule_type_runner.ts | 107 +- .../server/task_runner/task_runner.test.ts | 143 +- .../server/task_runner/task_runner.ts | 157 +- .../task_runner_alerts_client.test.ts | 36 +- .../task_runner/task_runner_cancel.test.ts | 50 +- .../task_runner/task_runner_factory.test.ts | 22 +- .../server/task_runner/task_runner_factory.ts | 21 +- .../alerting/server/task_runner/types.ts | 15 +- x-pack/plugins/alerting/server/types.ts | 4 +- .../plugins/event_log/generated/mappings.json | 15 + x-pack/plugins/event_log/generated/schemas.ts | 7 + x-pack/plugins/event_log/scripts/mappings.js | 15 + .../metric_threshold_executor.test.ts | 1 + .../custom_threshold_executor.test.ts | 1 + .../lib/rules/slo_burn_rate/executor.test.ts | 7 + .../technical_rule_field_map.test.ts | 5 + .../utils/create_lifecycle_rule_type.test.ts | 1 + .../create_persistence_rule_type_wrapper.ts | 12 +- .../server/utils/persistence_types.ts | 3 +- .../utils/rule_executor.test_helpers.ts | 1 + .../alerting.test.ts | 24 + .../feature_privilege_builder/alerting.ts | 4 + ...egacy_rules_notification_rule_type.test.ts | 1 + .../rule_preview/api/preview_rules/route.ts | 1 + .../create_security_rule_type_wrapper.ts | 6 +- .../factories/bulk_create_factory.ts | 6 +- .../rule_types/es_query/rule_type.test.ts | 1 + .../index_threshold/rule_type.test.ts | 4 + .../task_priority_check.test.ts.snap | 9 +- .../alerting_api_integration/common/config.ts | 2 +- .../common/plugins/aad/server/plugin.ts | 3 +- .../common/plugins/alerts/server/plugin.ts | 7 +- .../common/plugins/alerts/server/routes.ts | 29 +- .../server/{alert_types.ts => rule_types.ts} | 72 +- .../group1/tests/alerting/backfill/api_key.ts | 261 +++ .../group1/tests/alerting/backfill/delete.ts | 328 ++++ .../group1/tests/alerting/backfill/find.ts | 685 +++++++ .../group1/tests/alerting/backfill/get.ts | 373 ++++ .../group1/tests/alerting/backfill/index.ts | 20 + .../tests/alerting/backfill/schedule.ts | 1200 ++++++++++++ .../tests/alerting/backfill/task_runner.ts | 768 ++++++++ .../group1/tests/alerting/index.ts | 1 + .../group2/tests/alerting/update.ts | 18 +- .../tests/alerting/create_test_data.ts | 2 +- .../group4/alerts_as_data/alerts_as_data.ts | 15 + .../alerts_as_data_conflicts.ts | 1 + .../check_registered_task_types.ts | 1 + .../execution_logic/threat_match.ts | 1 + ...ove_random_valued_properties_from_alert.ts | 1 + .../common/alerting/alert_documents.ts | 6 + .../common/alerting/summary_actions.ts | 1 + 234 files changed, 17272 insertions(+), 1136 deletions(-) create mode 100644 x-pack/plugins/alerting/common/constants/ad_hoc_run_status.ts create mode 100644 x-pack/plugins/alerting/common/constants/backfill.ts create mode 100644 x-pack/plugins/alerting/common/constants/index.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/delete/index.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/delete/schemas/latest.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/delete/schemas/v1.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/delete/types/latest.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/delete/types/v1.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/find/index.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/find/schemas/latest.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/find/schemas/v1.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/find/types/latest.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/find/types/v1.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/get/index.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/get/schemas/latest.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/get/schemas/v1.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/get/types/latest.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/get/types/v1.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/schedule/index.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/schedule/schemas/latest.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/schedule/schemas/v1.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/schedule/types/latest.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/apis/schedule/types/v1.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/response/index.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/response/schemas/latest.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/response/schemas/v1.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/response/types/latest.ts create mode 100644 x-pack/plugins/alerting/common/routes/backfill/response/types/v1.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/delete/delete_backfill.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/delete/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/find/find_backfill.test.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/find/find_backfill.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/find/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/find_backfill_query_schema.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/find_backfill_result_schema.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/find/types/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/get/get_backfill.test.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/get/get_backfill.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/get/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/schedule/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_params_schema.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_result_schema.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/methods/schedule/types/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/result/schemas/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/result/types/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/transforms/index.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.test.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.test.ts create mode 100644 x-pack/plugins/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.ts create mode 100644 x-pack/plugins/alerting/server/backfill_client/backfill_client.mock.ts create mode 100644 x-pack/plugins/alerting/server/backfill_client/backfill_client.test.ts create mode 100644 x-pack/plugins/alerting/server/backfill_client/backfill_client.ts create mode 100644 x-pack/plugins/alerting/server/backfill_client/lib/calculate_schedule.test.ts create mode 100644 x-pack/plugins/alerting/server/backfill_client/lib/calculate_schedule.ts create mode 100644 x-pack/plugins/alerting/server/backfill_client/lib/create_backfill_error.ts create mode 100644 x-pack/plugins/alerting/server/backfill_client/lib/index.ts create mode 100644 x-pack/plugins/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts create mode 100644 x-pack/plugins/alerting/server/data/ad_hoc_run/types/index.ts create mode 100644 x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.test.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/delete/delete_backfill_route.test.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/delete/delete_backfill_route.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/find/find_backfill_route.test.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/find/find_backfill_route.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/index.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_request/latest.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_request/v1.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_response/latest.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_response/v1.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/get/get_backfill_route.test.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/get/get_backfill_route.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.test.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/index.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/latest.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/v1.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_response/latest.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_response/v1.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/transforms/index.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/latest.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.test.ts create mode 100644 x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/ad_hoc_run_params_model_versions.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/schemas/raw_ad_hoc_run_params/index.ts create mode 100644 x-pack/plugins/alerting/server/saved_objects/schemas/raw_ad_hoc_run_params/v1.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/ad_hoc_task_runner.test.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/ad_hoc_task_runner.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/ad_hoc_task_running_handler.test.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/ad_hoc_task_running_handler.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/lib/index.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/lib/partially_update_ad_hoc_run.test.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/lib/partially_update_ad_hoc_run.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/lib/process_run_result.test.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/lib/process_run_result.ts rename x-pack/plugins/alerting/server/task_runner/{running_handler.test.ts => rule_running_handler.test.ts} (89%) rename x-pack/plugins/alerting/server/task_runner/{running_handler.ts => rule_running_handler.ts} (98%) rename x-pack/test/alerting_api_integration/common/plugins/alerts/server/{alert_types.ts => rule_types.ts} (94%) create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/api_key.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/delete.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/find.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/get.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/schedule.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner.ts diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 268418457cc6a..0ddc830e4ed49 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -86,6 +86,10 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is creating a rule. | `failure` | User is not authorized to create a rule. +.2+| `ad_hoc_run_create` +| `unknown` | User is creating an ad hoc run. +| `failure` | User is not authorized to create an ad hoc run. + .2+| `space_create` | `unknown` | User is creating a space. | `failure` | User is not authorized to create a space. @@ -253,6 +257,10 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is deleting a rule. | `failure` | User is not authorized to delete a rule. +.2+| `ad_hoc_run_delete` +| `unknown` | User is deleting an ad hoc run. +| `failure` | User is not authorized to delete an ad hoc run. + .2+| `space_delete` | `unknown` | User is deleting a space. | `failure` | User is not authorized to delete a space. @@ -320,6 +328,18 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | User has accessed a rule as part of a search operation. | `failure` | User is not authorized to search for rules. +.2+| `rule_schedule_backfill` +| `success` | User has accessed a rule as part of a backfill schedule operation. +| `failure` | User is not authorized to access rule for backfill scheduling. + +.2+| `ad_hoc_run_get` +| `success` | User has accessed an ad hoc run. +| `failure` | User is not authorized to access ad hoc run. + +.2+| `ad_hoc_run_find` +| `success` | User has accessed an ad hoc run as part of a search operation. +| `failure` | User is not authorized to search for ad hoc runs. + .2+| `space_get` | `success` | User has accessed a space. | `failure` | User is not authorized to access a space. diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts index a04e762eaf67d..dee7da41e9a32 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts @@ -130,6 +130,7 @@ export const DEFAULT_INDEX_TYPES_MAP: IndexTypesMap = { export const HASH_TO_VERSION_MAP = { 'action_task_params|3d1b76c39bfb2cc8296b024d73854724': '10.0.0', 'action|0be88ebcc8560a075b6898236a202eb1': '10.0.0', + 'ad_hoc_run_params|6aa8806a2e27d3be492a1da0d7721845': '10.0.0', 'alert|96a5a144778243a9f4fece0e71c2197f': '10.0.0', 'api_key_pending_invalidation|16f515278a295f6245149ad7c5ddedb7': '10.0.0', 'apm-custom-dashboards|561810b957ac3c09fcfc08f32f168e97': '10.0.0', diff --git a/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts b/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts index 48320fd29e474..54a09c67d59ad 100644 --- a/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts +++ b/packages/kbn-alerts-as-data-utils/src/field_maps/alert_field_map.ts @@ -20,6 +20,7 @@ import { ALERT_REASON, ALERT_RULE_CATEGORY, ALERT_RULE_CONSUMER, + ALERT_RULE_EXECUTION_TIMESTAMP, ALERT_RULE_EXECUTION_UUID, ALERT_RULE_NAME, ALERT_RULE_PARAMETERS, @@ -118,6 +119,11 @@ export const alertFieldMap = { array: false, required: true, }, + [ALERT_RULE_EXECUTION_TIMESTAMP]: { + type: 'date', + array: false, + required: false, + }, [ALERT_RULE_EXECUTION_UUID]: { type: 'keyword', array: false, diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts index 7d1f9304eaa34..8a5d2a56bc329 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/alert_schema.ts @@ -94,6 +94,7 @@ const AlertOptional = rt.partial({ 'kibana.alert.last_detected': schemaDate, 'kibana.alert.maintenance_window_ids': schemaStringArray, 'kibana.alert.reason': schemaString, + 'kibana.alert.rule.execution.timestamp': schemaDate, 'kibana.alert.rule.execution.uuid': schemaString, 'kibana.alert.rule.parameters': schemaUnknown, 'kibana.alert.rule.tags': schemaStringArray, diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts index c57f9862f4327..fd585473fe596 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/security_schema.ts @@ -159,6 +159,7 @@ const SecurityAlertOptional = rt.partial({ 'kibana.alert.rule.created_by': schemaString, 'kibana.alert.rule.description': schemaString, 'kibana.alert.rule.enabled': schemaString, + 'kibana.alert.rule.execution.timestamp': schemaDate, 'kibana.alert.rule.execution.uuid': schemaString, 'kibana.alert.rule.from': schemaString, 'kibana.alert.rule.immutable': schemaStringArray, diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index d521bf999bd28..106a894fd4903 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -4,6 +4,15 @@ "name" ], "action_task_params": [], + "ad_hoc_run_params": [ + "apiKeyId", + "createdAt", + "end", + "rule", + "rule.alertTypeId", + "rule.consumer", + "start" + ], "alert": [ "actions", "actions.actionRef", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index f0bf6f2c260cc..25fa2c8335557 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -19,6 +19,33 @@ "dynamic": false, "properties": {} }, + "ad_hoc_run_params": { + "dynamic": false, + "properties": { + "apiKeyId": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "end": { + "type": "date" + }, + "rule": { + "properties": { + "alertTypeId": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + } + } + }, + "start": { + "type": "date" + } + } + }, "alert": { "dynamic": false, "properties": { diff --git a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts index dfd51bf737583..ce334e5d0fc55 100644 --- a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts +++ b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts @@ -82,6 +82,9 @@ const ALERT_RULE_CATEGORY = `${ALERT_RULE_NAMESPACE}.category` as const; // kibana.alert.rule.consumer - consumer for rule that generated this alert const ALERT_RULE_CONSUMER = `${ALERT_RULE_NAMESPACE}.consumer` as const; +// kibana.alert.rule.execution.timestamp - timestamp of the rule execution that generated this alert +const ALERT_RULE_EXECUTION_TIMESTAMP = `${ALERT_RULE_NAMESPACE}.execution.timestamp` as const; + // kibana.alert.rule.execution.uuid - unique ID for the rule execution that generated this alert const ALERT_RULE_EXECUTION_UUID = `${ALERT_RULE_NAMESPACE}.execution.uuid` as const; @@ -129,6 +132,7 @@ const fields = { ALERT_REASON, ALERT_RULE_CATEGORY, ALERT_RULE_CONSUMER, + ALERT_RULE_EXECUTION_TIMESTAMP, ALERT_RULE_EXECUTION_UUID, ALERT_RULE_NAME, ALERT_RULE_PARAMETERS, @@ -170,6 +174,7 @@ export { ALERT_REASON, ALERT_RULE_CATEGORY, ALERT_RULE_CONSUMER, + ALERT_RULE_EXECUTION_TIMESTAMP, ALERT_RULE_EXECUTION_UUID, ALERT_RULE_NAME, ALERT_RULE_PARAMETERS, diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 4586b4120d4d3..6990406853562 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -57,6 +57,7 @@ describe('checking migration metadata changes on all registered SO types', () => Object { "action": "cc93fe2c0c76e57c2568c63170e05daea897c136", "action_task_params": "96e27e7f4e8273ffcd87060221e2b75e81912dd5", + "ad_hoc_run_params": "d4e3c5c794151d0a4f5c71e886b2aa638da73ad2", "alert": "3a67d3f1db80af36bd57aaea47ecfef87e43c58f", "api_key_pending_invalidation": "1399e87ca37b3d3a65d269c924eda70726cfe886", "apm-custom-dashboards": "b67128f78160c288bd7efe25b2da6e2afd5e82fc", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts index d8d54319a35df..6b3d97206a094 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/type_registrations.test.ts @@ -13,6 +13,7 @@ import { createRoot } from '@kbn/core-test-helpers-kbn-server'; const previouslyRegisteredTypes = [ 'action', 'action_task_params', + 'ad_hoc_run_params', 'alert', 'api_key_pending_invalidation', 'apm-custom-dashboards', diff --git a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts index 9993c2ea2f0e5..22a3b9858599a 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group5/dot_kibana_split.test.ts @@ -179,6 +179,7 @@ describe('split .kibana index into multiple system indices', () => { ".kibana": Array [ "action", "action_task_params", + "ad_hoc_run_params", "alert", "api_key_pending_invalidation", "apm-custom-dashboards", diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 508a70874e37e..b14f2e30202b7 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -692,6 +692,8 @@ When a user is granted the `read` role in the Alerting Framework, they will be a - `getAlertSummary` - `getExecutionLog` - `find` +- `findBackfill` +- `getBackfill` 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: @@ -705,6 +707,8 @@ When a user is granted the `all` role in the Alerting Framework, they will be ab - `unmuteAll` - `muteAlert` - `unmuteAlert` +- `scheduleBackfill` +- `deleteBackfill` Finally, all users, whether they're granted any role or not, are privileged to call the following: diff --git a/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts b/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts index 8718fcb2db59f..ad28d03235caf 100644 --- a/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts +++ b/x-pack/plugins/alerting/common/alert_schema/field_maps/mapping_from_field_map.test.ts @@ -261,6 +261,9 @@ describe('mappingFromFieldMap', () => { }, execution: { properties: { + timestamp: { + type: 'date', + }, uuid: { type: 'keyword', }, diff --git a/x-pack/plugins/alerting/common/constants/ad_hoc_run_status.ts b/x-pack/plugins/alerting/common/constants/ad_hoc_run_status.ts new file mode 100644 index 0000000000000..2249717d2c38d --- /dev/null +++ b/x-pack/plugins/alerting/common/constants/ad_hoc_run_status.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const adHocRunStatus = { + COMPLETE: 'complete', + PENDING: 'pending', + RUNNING: 'running', + ERROR: 'error', + TIMEOUT: 'timeout', +} as const; +export type AdHocRunStatus = typeof adHocRunStatus[keyof typeof adHocRunStatus]; diff --git a/x-pack/plugins/alerting/common/constants/backfill.ts b/x-pack/plugins/alerting/common/constants/backfill.ts new file mode 100644 index 0000000000000..0a8281cba8186 --- /dev/null +++ b/x-pack/plugins/alerting/common/constants/backfill.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const MAX_SCHEDULE_BACKFILL_BULK_SIZE = 100; diff --git a/x-pack/plugins/alerting/common/constants/index.ts b/x-pack/plugins/alerting/common/constants/index.ts new file mode 100644 index 0000000000000..5dc50b91a4163 --- /dev/null +++ b/x-pack/plugins/alerting/common/constants/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { AdHocRunStatus } from './ad_hoc_run_status'; +export { adHocRunStatus } from './ad_hoc_run_status'; +export { MAX_SCHEDULE_BACKFILL_BULK_SIZE } from './backfill'; +export { PLUGIN } from './plugin'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/delete/index.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/index.ts new file mode 100644 index 0000000000000..8340d9577b1de --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { deleteParamsSchema } from './schemas/latest'; +export type { DeleteBackfillRequestParams } from './types/latest'; + +export { deleteParamsSchema as deleteParamsSchemaV1 } from './schemas/v1'; +export type { DeleteBackfillRequestParams as DeleteBackfillRequestParamsV1 } from './types/v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/delete/schemas/latest.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/delete/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/schemas/v1.ts new file mode 100644 index 0000000000000..46a65bfb01c3f --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/schemas/v1.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; + +export const deleteParamsSchema = schema.object({ + id: schema.string(), +}); diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/delete/types/latest.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/types/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/delete/types/v1.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/types/v1.ts new file mode 100644 index 0000000000000..3dfb1c8e78c0e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/types/v1.ts @@ -0,0 +1,11 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { deleteParamsSchemaV1 } from '..'; + +export type DeleteBackfillRequestParams = TypeOf; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/find/index.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/find/index.ts new file mode 100644 index 0000000000000..a4e94492f6698 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/find/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { findQuerySchema, findResponseSchema } from './schemas/latest'; +export type { + FindBackfillRequestQuery, + FindBackfillResponseBody, + FindBackfillResponse, +} from './types/latest'; + +export { + findQuerySchema as findQuerySchemaV1, + findResponseSchema as findResponseSchemaV1, +} from './schemas/v1'; +export type { + FindBackfillRequestQuery as FindBackfillRequestQueryV1, + FindBackfillResponseBody as FindBackfillResponseBodyV1, + FindBackfillResponse as FindBackfillResponseV1, +} from './types/v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/find/schemas/latest.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/find/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/find/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/find/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/find/schemas/v1.ts new file mode 100644 index 0000000000000..b285125af8590 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/find/schemas/v1.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import { backfillResponseSchemaV1 } from '../../../response'; + +export const findQuerySchema = schema.object( + { + end: schema.maybe(schema.string()), + page: schema.number({ defaultValue: 1, min: 1 }), + per_page: schema.number({ defaultValue: 10, min: 0 }), + rule_ids: schema.maybe(schema.string()), + start: schema.maybe(schema.string()), + sort_field: schema.maybe(schema.oneOf([schema.literal('createdAt'), schema.literal('start')])), + sort_order: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])), + }, + { + validate({ start, end }) { + if (start) { + const parsedStart = Date.parse(start); + if (isNaN(parsedStart)) { + return `[start]: query start must be valid date`; + } + } + if (end) { + const parsedEnd = Date.parse(end); + if (isNaN(parsedEnd)) { + return `[end]: query end must be valid date`; + } + } + }, + } +); + +export const findResponseSchema = schema.object({ + page: schema.number(), + per_page: schema.number(), + total: schema.number(), + data: schema.arrayOf(backfillResponseSchemaV1), +}); diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/find/types/latest.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/find/types/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/find/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/find/types/v1.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/find/types/v1.ts new file mode 100644 index 0000000000000..90f3ccf8592c6 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/find/types/v1.ts @@ -0,0 +1,16 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { findQuerySchemaV1, findResponseSchemaV1 } from '..'; + +export type FindBackfillRequestQuery = TypeOf; +export type FindBackfillResponseBody = TypeOf; + +export interface FindBackfillResponse { + body: FindBackfillResponseBody; +} diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/get/index.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/get/index.ts new file mode 100644 index 0000000000000..27749cbcc291d --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/get/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getParamsSchema, getResponseSchema } from './schemas/latest'; +export type { + GetBackfillRequestParams, + GetBackfillResponseBody, + GetBackfillResponse, +} from './types/latest'; + +export { + getParamsSchema as getParamsSchemaV1, + getResponseSchema as getResponseSchemaV1, +} from './schemas/v1'; +export type { + GetBackfillRequestParams as GetBackfillRequestParamsV1, + GetBackfillResponseBody as GetBackfillResponseBodyV1, + GetBackfillResponse as GetBackfillResponseV1, +} from './types/v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/get/schemas/latest.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/get/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/get/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/get/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/get/schemas/v1.ts new file mode 100644 index 0000000000000..f377ec880f58a --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/get/schemas/v1.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import { backfillResponseSchemaV1 } from '../../../response'; + +export const getParamsSchema = schema.object({ + id: schema.string(), +}); + +export const getResponseSchema = backfillResponseSchemaV1; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/get/types/latest.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/get/types/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/get/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/get/types/v1.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/get/types/v1.ts new file mode 100644 index 0000000000000..199bac2fe8435 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/get/types/v1.ts @@ -0,0 +1,16 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { getParamsSchemaV1, getResponseSchemaV1 } from '..'; + +export type GetBackfillRequestParams = TypeOf; +export type GetBackfillResponseBody = TypeOf; + +export interface GetBackfillResponse { + body: GetBackfillResponseBody; +} diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/index.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/index.ts new file mode 100644 index 0000000000000..d570b99976012 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { scheduleBodySchema, scheduleResponseSchema } from './schemas/latest'; +export type { + ScheduleBackfillRequestBody, + ScheduleBackfillResponseBody, + ScheduleBackfillResponse, +} from './types/latest'; + +export { + scheduleBodySchema as scheduleBodySchemaV1, + scheduleResponseSchema as scheduleResponseSchemaV1, +} from './schemas/v1'; +export type { + ScheduleBackfillRequestBody as ScheduleBackfillRequestBodyV1, + ScheduleBackfillResponseBody as ScheduleBackfillResponseBodyV1, + ScheduleBackfillResponse as ScheduleBackfillResponseV1, +} from './types/v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/schemas/latest.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/schemas/v1.ts new file mode 100644 index 0000000000000..791a5cce9ac38 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/schemas/v1.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { schema } from '@kbn/config-schema'; +import { MAX_SCHEDULE_BACKFILL_BULK_SIZE } from '../../../../../constants'; +import { backfillResponseSchemaV1, errorResponseSchemaV1 } from '../../../response'; + +export const scheduleBodySchema = schema.arrayOf( + schema.object( + { + rule_id: schema.string(), + start: schema.string(), + end: schema.maybe(schema.string()), + }, + { + validate({ start, end }) { + const parsedStart = Date.parse(start); + if (isNaN(parsedStart)) { + return `Backfill start must be valid date`; + } + + if (end) { + const parsedEnd = Date.parse(end); + if (isNaN(parsedEnd)) { + return `Backfill end must be valid date`; + } + const startMs = new Date(start).valueOf(); + const endMs = new Date(end).valueOf(); + if (endMs <= startMs) { + return `Backfill end must be greater than backfill start`; + } + } + }, + } + ), + { minSize: 1, maxSize: MAX_SCHEDULE_BACKFILL_BULK_SIZE } +); + +export const scheduleResponseSchema = schema.arrayOf( + schema.oneOf([backfillResponseSchemaV1, errorResponseSchemaV1]) +); diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/types/latest.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/types/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/types/v1.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/types/v1.ts new file mode 100644 index 0000000000000..3fe5c2989f648 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/types/v1.ts @@ -0,0 +1,16 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { scheduleBodySchemaV1, scheduleResponseSchemaV1 } from '..'; + +export type ScheduleBackfillRequestBody = TypeOf; +export type ScheduleBackfillResponseBody = TypeOf; + +export interface ScheduleBackfillResponse { + body: ScheduleBackfillResponseBody; +} diff --git a/x-pack/plugins/alerting/common/routes/backfill/response/index.ts b/x-pack/plugins/alerting/common/routes/backfill/response/index.ts new file mode 100644 index 0000000000000..358179c8bd7c2 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/response/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { backfillResponseSchema, errorResponseSchema } from './schemas/latest'; +export type { BackfillResponse } from './types/latest'; + +export { + backfillResponseSchema as backfillResponseSchemaV1, + errorResponseSchema as errorResponseSchemaV1, +} from './schemas/v1'; +export type { + BackfillResponse as BackfillResponseV1, + ErrorResponse as ErrorResponseV1, +} from './types/v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/response/schemas/latest.ts b/x-pack/plugins/alerting/common/routes/backfill/response/schemas/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/response/schemas/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/response/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/backfill/response/schemas/v1.ts new file mode 100644 index 0000000000000..8db238b89ee81 --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/response/schemas/v1.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { adHocRunStatus } from '../../../../constants'; + +export const statusSchema = schema.oneOf([ + schema.literal(adHocRunStatus.COMPLETE), + schema.literal(adHocRunStatus.PENDING), + schema.literal(adHocRunStatus.RUNNING), + schema.literal(adHocRunStatus.ERROR), + schema.literal(adHocRunStatus.TIMEOUT), +]); + +export const backfillResponseSchema = schema.object({ + id: schema.string(), + created_at: schema.string(), + duration: schema.string(), + enabled: schema.boolean(), + rule: schema.object({ + id: schema.string(), + name: schema.string(), + tags: schema.arrayOf(schema.string()), + rule_type_id: schema.string(), + params: schema.recordOf(schema.string(), schema.maybe(schema.any())), + api_key_owner: schema.nullable(schema.string()), + api_key_created_by_user: schema.maybe(schema.nullable(schema.boolean())), + consumer: schema.string(), + enabled: schema.boolean(), + schedule: schema.object({ interval: schema.string() }), + created_by: schema.nullable(schema.string()), + updated_by: schema.nullable(schema.string()), + created_at: schema.string(), + updated_at: schema.string(), + revision: schema.number(), + }), + space_id: schema.string(), + start: schema.string(), + status: statusSchema, + end: schema.maybe(schema.string()), + schedule: schema.arrayOf( + schema.object({ + run_at: schema.string(), + status: statusSchema, + interval: schema.string(), + }) + ), +}); + +export const errorResponseSchema = schema.object({ + error: schema.object({ + error: schema.string(), + message: schema.string(), + }), +}); diff --git a/x-pack/plugins/alerting/common/routes/backfill/response/types/latest.ts b/x-pack/plugins/alerting/common/routes/backfill/response/types/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/response/types/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/common/routes/backfill/response/types/v1.ts b/x-pack/plugins/alerting/common/routes/backfill/response/types/v1.ts new file mode 100644 index 0000000000000..ff0d94f164f7e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/response/types/v1.ts @@ -0,0 +1,12 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { backfillResponseSchemaV1, errorResponseSchemaV1 } from '..'; + +export type BackfillResponse = TypeOf; +export type ErrorResponse = TypeOf; diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts index 36dc3761185ff..b3316083d790f 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts @@ -24,6 +24,7 @@ import { ALERT_MAINTENANCE_WINDOW_IDS, ALERT_RULE_CATEGORY, ALERT_RULE_CONSUMER, + ALERT_RULE_EXECUTION_TIMESTAMP, ALERT_RULE_EXECUTION_UUID, ALERT_RULE_NAME, ALERT_RULE_PARAMETERS, @@ -226,6 +227,7 @@ const getNewIndexedAlertDoc = (overrides = {}) => ({ [ALERT_RULE_CATEGORY]: 'My test rule', [ALERT_RULE_CONSUMER]: 'bar', [ALERT_RULE_EXECUTION_UUID]: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + [ALERT_RULE_EXECUTION_TIMESTAMP]: date, [ALERT_RULE_NAME]: 'rule-name', [ALERT_RULE_PARAMETERS]: { bar: true }, [ALERT_RULE_PRODUCER]: 'alerts', @@ -673,6 +675,7 @@ describe('Alerts Client', () => { }, }, [TIMESTAMP]: date, + [ALERT_RULE_EXECUTION_TIMESTAMP]: date, [EVENT_ACTION]: 'active', [ALERT_ACTION_GROUP]: 'default', [ALERT_CONSECUTIVE_MATCHES]: 1, @@ -954,6 +957,7 @@ describe('Alerts Client', () => { }, }, [TIMESTAMP]: date, + [ALERT_RULE_EXECUTION_TIMESTAMP]: date, [EVENT_ACTION]: 'active', [ALERT_ACTION_GROUP]: 'default', [ALERT_CONSECUTIVE_MATCHES]: 1, @@ -1002,6 +1006,7 @@ describe('Alerts Client', () => { }, }, [TIMESTAMP]: date, + [ALERT_RULE_EXECUTION_TIMESTAMP]: date, [EVENT_ACTION]: 'close', [ALERT_ACTION_GROUP]: 'recovered', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -1099,6 +1104,7 @@ describe('Alerts Client', () => { // ongoing alert doc getOngoingIndexedAlertDoc({ [TIMESTAMP]: startedAtDate, + [ALERT_RULE_EXECUTION_TIMESTAMP]: startedAtDate, [ALERT_UUID]: 'def', [ALERT_INSTANCE_ID]: '2', [ALERT_FLAPPING_HISTORY]: [true, false, false, false], @@ -1112,6 +1118,7 @@ describe('Alerts Client', () => { // new alert doc getNewIndexedAlertDoc({ [TIMESTAMP]: startedAtDate, + [ALERT_RULE_EXECUTION_TIMESTAMP]: startedAtDate, [ALERT_UUID]: uuid3, [ALERT_INSTANCE_ID]: '3', [ALERT_START]: startedAtDate, @@ -1129,6 +1136,118 @@ describe('Alerts Client', () => { // recovered alert doc getRecoveredIndexedAlertDoc({ [TIMESTAMP]: startedAtDate, + [ALERT_RULE_EXECUTION_TIMESTAMP]: startedAtDate, + [ALERT_DURATION]: 1951841000, + [ALERT_UUID]: 'abc', + [ALERT_END]: startedAtDate, + [ALERT_TIME_RANGE]: { gte: '2023-03-28T12:27:28.159Z', lte: startedAtDate }, + }), + ], + }); + }); + + test('should use runTimestamp time if provided', async () => { + const runTimestamp = '2023-10-01T00:00:00.000Z'; + clusterClient.search.mockResolvedValue({ + took: 10, + timed_out: false, + _shards: { failed: 0, successful: 1, total: 1, skipped: 0 }, + hits: { + total: { relation: 'eq', value: 2 }, + hits: [ + { + _id: 'abc', + _index: '.internal.alerts-test.alerts-default-000001', + _seq_no: 41, + _primary_term: 665, + _source: fetchedAlert1, + }, + { + _id: 'def', + _index: '.internal.alerts-test.alerts-default-000002', + _seq_no: 42, + _primary_term: 666, + _source: fetchedAlert2, + }, + ], + }, + }); + const alertsClient = new AlertsClient<{}, {}, {}, 'default', 'recovered'>( + alertsClientParams + ); + + await alertsClient.initializeExecution({ + ...defaultExecutionOpts, + activeAlertsFromState: { + '1': trackedAlert1Raw, + '2': trackedAlert2Raw, + }, + runTimestamp: new Date(runTimestamp), + startedAt: new Date(startedAtDate), + }); + + // Report 1 new alert and 1 active alert, recover 1 alert + const alertExecutorService = alertsClient.factory(); + alertExecutorService.create('2').scheduleActions('default'); + alertExecutorService.create('3').scheduleActions('default'); + + alertsClient.processAndLogAlerts(processAndLogAlertsOpts); + + await alertsClient.persistAlerts(); + + const { alertsToReturn } = alertsClient.getAlertsToSerialize(); + const uuid3 = alertsToReturn['3'].meta?.uuid; + + expect(clusterClient.bulk).toHaveBeenCalledWith({ + index: '.alerts-test.alerts-default', + refresh: true, + require_alias: !useDataStreamForAlerts, + body: [ + { + index: { + _id: 'def', + _index: '.internal.alerts-test.alerts-default-000002', + if_seq_no: 42, + if_primary_term: 666, + require_alias: false, + }, + }, + // ongoing alert doc + getOngoingIndexedAlertDoc({ + [TIMESTAMP]: startedAtDate, + [ALERT_RULE_EXECUTION_TIMESTAMP]: runTimestamp, + [ALERT_UUID]: 'def', + [ALERT_INSTANCE_ID]: '2', + [ALERT_FLAPPING_HISTORY]: [true, false, false, false], + [ALERT_DURATION]: 37951841000, + [ALERT_START]: '2023-03-28T02:27:28.159Z', + [ALERT_TIME_RANGE]: { gte: '2023-03-28T02:27:28.159Z' }, + }), + { + create: { _id: uuid3, ...(useDataStreamForAlerts ? {} : { require_alias: true }) }, + }, + // new alert doc + getNewIndexedAlertDoc({ + [TIMESTAMP]: startedAtDate, + [ALERT_RULE_EXECUTION_TIMESTAMP]: runTimestamp, + [ALERT_UUID]: uuid3, + [ALERT_INSTANCE_ID]: '3', + [ALERT_START]: startedAtDate, + [ALERT_TIME_RANGE]: { gte: startedAtDate }, + }), + { + index: { + _id: 'abc', + _index: '.internal.alerts-test.alerts-default-000001', + if_seq_no: 41, + if_primary_term: 665, + require_alias: false, + }, + }, + // recovered alert doc + getRecoveredIndexedAlertDoc({ + [TIMESTAMP]: startedAtDate, + [ALERT_RULE_EXECUTION_TIMESTAMP]: runTimestamp, [ALERT_DURATION]: 1951841000, [ALERT_UUID]: 'abc', [ALERT_END]: startedAtDate, diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts index ff656f64be4e4..6267d0785f381 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -103,6 +103,7 @@ export class AlertsClient< }; private startedAtString: string | null = null; + private runTimestampString: string | undefined; private rule: AlertRule; private ruleType: UntypedNormalizedRuleType; @@ -132,6 +133,9 @@ export class AlertsClient< public async initializeExecution(opts: InitializeExecutionOpts) { this.startedAtString = opts.startedAt ? opts.startedAt.toISOString() : null; + if (opts.runTimestamp) { + this.runTimestampString = opts.runTimestamp.toISOString(); + } await this.legacyAlertsClient.initializeExecution(opts); if (!this.ruleType.alerts?.shouldWrite) { @@ -438,6 +442,7 @@ export class AlertsClient< alert: this.fetchedAlerts.data[id], legacyAlert: activeAlerts[id], rule: this.rule, + runTimestamp: this.runTimestampString, timestamp: currentTime, payload: this.reportedAlerts[id], kibanaVersion: this.options.kibanaVersion, @@ -459,6 +464,7 @@ export class AlertsClient< >({ legacyAlert: activeAlerts[id], rule: this.rule, + runTimestamp: this.runTimestampString, timestamp: currentTime, payload: this.reportedAlerts[id], kibanaVersion: this.options.kibanaVersion, @@ -489,6 +495,7 @@ export class AlertsClient< alert: this.fetchedAlerts.data[id], legacyAlert: currentRecoveredAlerts[id], rule: this.rule, + runTimestamp: this.runTimestampString, timestamp: currentTime, payload: this.reportedAlerts[id], recoveryActionGroup: this.options.ruleType.recoveryActionGroup.id, @@ -497,6 +504,7 @@ export class AlertsClient< : buildUpdatedRecoveredAlert({ alert: this.fetchedAlerts.data[id], legacyRawAlert: recoveredAlertsToReturn[id], + runTimestamp: this.runTimestampString, timestamp: currentTime, rule: this.rule, }) diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts index 47c4e9e5f4f59..280c49df36ed0 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts @@ -26,6 +26,7 @@ import { VERSION, ALERT_TIME_RANGE, ALERT_CONSECUTIVE_MATCHES, + ALERT_RULE_EXECUTION_TIMESTAMP, } from '@kbn/rule-data-utils'; import { alertRule } from './test_fixtures'; @@ -52,6 +53,7 @@ describe('buildNewAlert', () => { [ALERT_FLAPPING_HISTORY]: [], [ALERT_INSTANCE_ID]: 'alert-A', [ALERT_MAINTENANCE_WINDOW_IDS]: [], + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-28T12:27:28.159Z', [ALERT_STATUS]: 'active', [ALERT_UUID]: legacyAlert.getUuid(), [ALERT_WORKFLOW_STATUS]: 'open', @@ -76,6 +78,7 @@ describe('buildNewAlert', () => { ).toEqual({ ...alertRule, [TIMESTAMP]: '2023-03-28T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-28T12:27:28.159Z', [EVENT_ACTION]: 'open', [EVENT_KIND]: 'signal', [ALERT_ACTION_GROUP]: 'default', @@ -112,6 +115,7 @@ describe('buildNewAlert', () => { ).toEqual({ ...alertRule, [TIMESTAMP]: '2023-03-28T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-28T12:27:28.159Z', [EVENT_ACTION]: 'open', [EVENT_KIND]: 'signal', [ALERT_ACTION_GROUP]: 'default', @@ -153,6 +157,7 @@ describe('buildNewAlert', () => { url: `https://url1`, 'kibana.alert.nested_field': 2, [TIMESTAMP]: '2023-03-28T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-28T12:27:28.159Z', [EVENT_ACTION]: 'open', [EVENT_KIND]: 'signal', [ALERT_ACTION_GROUP]: 'default', @@ -170,6 +175,39 @@ describe('buildNewAlert', () => { }); }); + test('should use runTimestamp if provided', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert.scheduleActions('default'); + + expect( + buildNewAlert<{}, {}, {}, 'default', 'recovered'>({ + legacyAlert, + rule: alertRule, + runTimestamp: '2030-12-15T02:44:13.124Z', + timestamp: '2023-03-28T12:27:28.159Z', + kibanaVersion: '8.9.0', + }) + ).toEqual({ + ...alertRule, + [TIMESTAMP]: '2023-03-28T12:27:28.159Z', + [EVENT_ACTION]: 'open', + [EVENT_KIND]: 'signal', + [ALERT_ACTION_GROUP]: 'default', + [ALERT_CONSECUTIVE_MATCHES]: 0, + [ALERT_FLAPPING]: false, + [ALERT_FLAPPING_HISTORY]: [], + [ALERT_INSTANCE_ID]: 'alert-A', + [ALERT_MAINTENANCE_WINDOW_IDS]: [], + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2030-12-15T02:44:13.124Z', + [ALERT_STATUS]: 'active', + [ALERT_UUID]: legacyAlert.getUuid(), + [ALERT_WORKFLOW_STATUS]: 'open', + [SPACE_IDS]: ['default'], + [VERSION]: '8.9.0', + [TAGS]: ['rule-', '-tags'], + }); + }); + test('should use workflow status from alert payload if set', () => { const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); legacyAlert.scheduleActions('default'); @@ -199,6 +237,7 @@ describe('buildNewAlert', () => { url: `https://url1`, 'kibana.alert.nested_field': 2, [TIMESTAMP]: '2023-03-28T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-28T12:27:28.159Z', [EVENT_ACTION]: 'open', [EVENT_KIND]: 'signal', [ALERT_ACTION_GROUP]: 'default', @@ -250,6 +289,7 @@ describe('buildNewAlert', () => { url: `https://url1`, 'kibana.alert.nested_field': 2, [TIMESTAMP]: '2023-03-28T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-28T12:27:28.159Z', [EVENT_ACTION]: 'open', [EVENT_KIND]: 'signal', [ALERT_ACTION_GROUP]: 'default', @@ -303,6 +343,7 @@ describe('buildNewAlert', () => { url: `https://url1`, 'kibana.alert.nested_field': 2, [TIMESTAMP]: '2023-03-28T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-28T12:27:28.159Z', [EVENT_ACTION]: 'open', [EVENT_KIND]: 'signal', [ALERT_ACTION_GROUP]: 'default', diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts index 911c0cc8c6c9b..cc77099a11623 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts @@ -27,6 +27,7 @@ import { TAGS, TIMESTAMP, VERSION, + ALERT_RULE_EXECUTION_TIMESTAMP, } from '@kbn/rule-data-utils'; import { DeepPartial } from '@kbn/utility-types'; import { Alert as LegacyAlert } from '../../alert/alert'; @@ -45,6 +46,7 @@ interface BuildNewAlertOpts< legacyAlert: LegacyAlert; rule: AlertRule; payload?: DeepPartial; + runTimestamp?: string; timestamp: string; kibanaVersion: string; } @@ -63,6 +65,7 @@ export const buildNewAlert = < >({ legacyAlert, rule, + runTimestamp, timestamp, payload, kibanaVersion, @@ -82,6 +85,7 @@ export const buildNewAlert = < [TIMESTAMP]: timestamp, [EVENT_ACTION]: 'open', [EVENT_KIND]: 'signal', + [ALERT_RULE_EXECUTION_TIMESTAMP]: runTimestamp ?? timestamp, [ALERT_ACTION_GROUP]: legacyAlert.getScheduledActionOptions()?.actionGroup, [ALERT_FLAPPING]: legacyAlert.getFlapping(), [ALERT_FLAPPING_HISTORY]: legacyAlert.getFlappingHistory(), diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts index 7e76a829d0d35..136a2b62962be 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts @@ -27,6 +27,7 @@ import { VERSION, ALERT_TIME_RANGE, ALERT_CONSECUTIVE_MATCHES, + ALERT_RULE_EXECUTION_TIMESTAMP, } from '@kbn/rule-data-utils'; import { alertRule, existingFlattenedNewAlert, existingExpandedNewAlert } from './test_fixtures'; @@ -54,6 +55,7 @@ for (const flattened of [true, false]) { ).toEqual({ ...alertRule, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'active', [ALERT_ACTION_GROUP]: 'warning', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -114,6 +116,7 @@ for (const flattened of [true, false]) { ).toEqual({ ...updatedRule, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'active', [ALERT_ACTION_GROUP]: 'warning', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -191,6 +194,7 @@ for (const flattened of [true, false]) { ).toEqual({ ...alertRule, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'active', [ALERT_ACTION_GROUP]: 'error', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -281,6 +285,7 @@ for (const flattened of [true, false]) { url: `https://url2`, 'kibana.alert.nested_field': 2, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'active', [ALERT_ACTION_GROUP]: 'warning', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -316,6 +321,63 @@ for (const flattened of [true, false]) { }); }); + test('should return alert document with updated runTimestamp if specified', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('alert-A', { + meta: { uuid: 'abcdefg' }, + }); + legacyAlert + .scheduleActions('warning') + .replaceState({ start: '2023-03-28T12:27:28.159Z', duration: '36000000' }); + + expect( + buildOngoingAlert<{}, {}, {}, 'error' | 'warning', 'recovered'>({ + // @ts-expect-error + alert: existingAlert, + legacyAlert, + rule: alertRule, + runTimestamp: '2030-12-15T02:44:13.124Z', + timestamp: '2023-03-29T12:27:28.159Z', + kibanaVersion: '8.9.0', + }) + ).toEqual({ + ...alertRule, + [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2030-12-15T02:44:13.124Z', + [EVENT_ACTION]: 'active', + [ALERT_ACTION_GROUP]: 'warning', + [ALERT_CONSECUTIVE_MATCHES]: 0, + [ALERT_FLAPPING]: false, + [ALERT_FLAPPING_HISTORY]: [], + [ALERT_MAINTENANCE_WINDOW_IDS]: [], + [ALERT_DURATION]: 36000, + [ALERT_STATUS]: 'active', + [ALERT_TIME_RANGE]: { gte: '2023-03-28T12:27:28.159Z' }, + [ALERT_WORKFLOW_STATUS]: 'open', + [SPACE_IDS]: ['default'], + [VERSION]: '8.9.0', + [TAGS]: ['rule-', '-tags'], + ...(flattened + ? { + [EVENT_KIND]: 'signal', + [ALERT_INSTANCE_ID]: 'alert-A', + [ALERT_START]: '2023-03-28T12:27:28.159Z', + [ALERT_UUID]: 'abcdefg', + } + : { + event: { + kind: 'signal', + }, + kibana: { + alert: { + instance: { id: 'alert-A' }, + start: '2023-03-28T12:27:28.159Z', + uuid: 'abcdefg', + }, + }, + }), + }); + }); + test('should return alert document with updated payload if specified but not overwrite any framework fields', () => { const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('alert-A', { meta: { uuid: 'abcdefg' }, @@ -378,6 +440,7 @@ for (const flattened of [true, false]) { url: `https://url2`, 'kibana.alert.nested_field': 2, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'active', [ALERT_ACTION_GROUP]: 'warning', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -479,6 +542,7 @@ for (const flattened of [true, false]) { url: `https://url2`, 'kibana.alert.nested_field': 2, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'active', [ALERT_ACTION_GROUP]: 'warning', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -560,6 +624,7 @@ for (const flattened of [true, false]) { count: 1, url: `https://url1`, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'active', [ALERT_ACTION_GROUP]: 'warning', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -659,6 +724,7 @@ for (const flattened of [true, false]) { url: `https://url2`, 'kibana.alert.deeply.nested_field': 2, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'active', [ALERT_ACTION_GROUP]: 'warning', [ALERT_CONSECUTIVE_MATCHES]: 0, diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts index 8d1be2e75ecb0..6c62005873221 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts @@ -14,6 +14,7 @@ import { ALERT_FLAPPING, ALERT_FLAPPING_HISTORY, ALERT_MAINTENANCE_WINDOW_IDS, + ALERT_RULE_EXECUTION_TIMESTAMP, ALERT_RULE_TAGS, ALERT_TIME_RANGE, EVENT_ACTION, @@ -41,6 +42,7 @@ interface BuildOngoingAlertOpts< legacyAlert: LegacyAlert; rule: AlertRule; payload?: DeepPartial; + runTimestamp?: string; timestamp: string; kibanaVersion: string; } @@ -61,6 +63,7 @@ export const buildOngoingAlert = < legacyAlert, payload, rule, + runTimestamp, timestamp, kibanaVersion, }: BuildOngoingAlertOpts< @@ -81,6 +84,7 @@ export const buildOngoingAlert = < // Update the timestamp to reflect latest update time [TIMESTAMP]: timestamp, [EVENT_ACTION]: 'active', + [ALERT_RULE_EXECUTION_TIMESTAMP]: runTimestamp ?? timestamp, // Because we're building this alert after the action execution handler has been // run, the scheduledExecutionOptions for the alert has been cleared and // the lastScheduledActions has been set. If we ever change the order of operations diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts index 3b4f23ac7cb4c..ebaa829c0988b 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts @@ -28,6 +28,7 @@ import { ALERT_TIME_RANGE, ALERT_END, ALERT_CONSECUTIVE_MATCHES, + ALERT_RULE_EXECUTION_TIMESTAMP, } from '@kbn/rule-data-utils'; import { alertRule, @@ -62,6 +63,7 @@ for (const flattened of [true, false]) { ).toEqual({ ...alertRule, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'close', [ALERT_ACTION_GROUP]: 'recovered', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -127,6 +129,7 @@ for (const flattened of [true, false]) { ).toEqual({ ...updatedRule, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'close', [ALERT_ACTION_GROUP]: 'NoLongerActive', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -222,6 +225,7 @@ for (const flattened of [true, false]) { url: `https://url2`, 'kibana.alert.nested_field': 2, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'close', [ALERT_ACTION_GROUP]: 'NoLongerActive', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -257,6 +261,66 @@ for (const flattened of [true, false]) { }); }); + test('should return alert document with updated runTimestamp if specified', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A', { + meta: { uuid: 'abcdefg' }, + }); + legacyAlert.scheduleActions('default').replaceState({ + start: '2023-03-28T12:27:28.159Z', + end: '2023-03-30T12:27:28.159Z', + duration: '36000000', + }); + + expect( + buildRecoveredAlert<{}, {}, {}, 'default', 'recovered'>({ + // @ts-expect-error + alert: existingAlert, + legacyAlert, + rule: alertRule, + runTimestamp: '2030-12-15T02:44:13.124Z', + recoveryActionGroup: 'recovered', + timestamp: '2023-03-29T12:27:28.159Z', + kibanaVersion: '8.9.0', + }) + ).toEqual({ + ...alertRule, + [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2030-12-15T02:44:13.124Z', + [EVENT_ACTION]: 'close', + [ALERT_ACTION_GROUP]: 'recovered', + [ALERT_CONSECUTIVE_MATCHES]: 0, + [ALERT_FLAPPING]: false, + [ALERT_FLAPPING_HISTORY]: [], + [ALERT_MAINTENANCE_WINDOW_IDS]: [], + [ALERT_STATUS]: 'recovered', + [ALERT_WORKFLOW_STATUS]: 'open', + [ALERT_DURATION]: 36000, + [ALERT_START]: '2023-03-28T12:27:28.159Z', + [ALERT_END]: '2023-03-30T12:27:28.159Z', + [ALERT_TIME_RANGE]: { gte: '2023-03-28T12:27:28.159Z', lte: '2023-03-30T12:27:28.159Z' }, + [SPACE_IDS]: ['default'], + [VERSION]: '8.9.0', + [TAGS]: ['rule-', '-tags'], + ...(flattened + ? { + [EVENT_KIND]: 'signal', + [ALERT_INSTANCE_ID]: 'alert-A', + [ALERT_UUID]: 'abcdefg', + } + : { + event: { + kind: 'signal', + }, + kibana: { + alert: { + instance: { id: 'alert-A' }, + uuid: 'abcdefg', + }, + }, + }), + }); + }); + test('should merge and de-dupe tags from existing flattened alert, reported recovery payload and rule tags', () => { const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A', { meta: { uuid: 'abcdefg' }, @@ -325,6 +389,7 @@ for (const flattened of [true, false]) { url: `https://url2`, 'kibana.alert.nested_field': 2, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'close', [ALERT_ACTION_GROUP]: 'NoLongerActive', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -426,6 +491,7 @@ for (const flattened of [true, false]) { url: `https://url2`, 'kibana.alert.nested_field': 2, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'close', [ALERT_ACTION_GROUP]: 'NoLongerActive', [ALERT_CONSECUTIVE_MATCHES]: 0, @@ -526,6 +592,7 @@ for (const flattened of [true, false]) { url: `https://url2`, 'kibana.alert.deeply.nested_field': 2, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [EVENT_ACTION]: 'close', [ALERT_ACTION_GROUP]: 'NoLongerActive', [ALERT_CONSECUTIVE_MATCHES]: 0, diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts index 46f36c9715e11..0f874d857736e 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts @@ -23,6 +23,7 @@ import { ALERT_TIME_RANGE, ALERT_START, ALERT_CONSECUTIVE_MATCHES, + ALERT_RULE_EXECUTION_TIMESTAMP, } from '@kbn/rule-data-utils'; import { DeepPartial } from '@kbn/utility-types'; import { Alert as LegacyAlert } from '../../alert/alert'; @@ -42,6 +43,7 @@ interface BuildRecoveredAlertOpts< alert: Alert & AlertData; legacyAlert: LegacyAlert; rule: AlertRule; + runTimestamp?: string; recoveryActionGroup: string; payload?: DeepPartial; timestamp: string; @@ -65,6 +67,7 @@ export const buildRecoveredAlert = < rule, timestamp, payload, + runTimestamp, recoveryActionGroup, kibanaVersion, }: BuildRecoveredAlertOpts< @@ -85,6 +88,7 @@ export const buildRecoveredAlert = < // Update the timestamp to reflect latest update time [TIMESTAMP]: timestamp, [EVENT_ACTION]: 'close', + [ALERT_RULE_EXECUTION_TIMESTAMP]: runTimestamp ?? timestamp, // Set the recovery action group [ALERT_ACTION_GROUP]: recoveryActionGroup, // Set latest flapping state diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_updated_recovered_alert.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_updated_recovered_alert.test.ts index 93ea493bee9cc..b953814d4151e 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/build_updated_recovered_alert.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_updated_recovered_alert.test.ts @@ -25,6 +25,7 @@ import { VERSION, ALERT_TIME_RANGE, ALERT_END, + ALERT_RULE_EXECUTION_TIMESTAMP, ALERT_CONSECUTIVE_MATCHES, } from '@kbn/rule-data-utils'; import { @@ -59,6 +60,56 @@ describe('buildUpdatedRecoveredAlert', () => { ).toEqual({ ...alertRule, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [EVENT_ACTION]: 'close', + [EVENT_KIND]: 'signal', + [ALERT_ACTION_GROUP]: 'recovered', + [ALERT_DURATION]: '36000000', + [ALERT_START]: '2023-03-27T12:27:28.159Z', + [ALERT_END]: '2023-03-30T12:27:28.159Z', + [ALERT_TIME_RANGE]: { gte: '2023-03-27T12:27:28.159Z', lte: '2023-03-30T12:27:28.159Z' }, + [ALERT_FLAPPING]: true, + [ALERT_FLAPPING_HISTORY]: [false, false, true, true], + [ALERT_INSTANCE_ID]: 'alert-A', + [ALERT_MAINTENANCE_WINDOW_IDS]: ['maint-x'], + [ALERT_STATUS]: 'recovered', + [ALERT_START]: '2023-03-28T12:27:28.159Z', + [ALERT_UUID]: 'abcdefg', + [ALERT_WORKFLOW_STATUS]: 'open', + [SPACE_IDS]: ['default'], + [VERSION]: '8.8.1', + [TAGS]: ['rule-', '-tags'], + [ALERT_CONSECUTIVE_MATCHES]: 0, + }); + }); + + test('should update with runTimestamp if specified', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert.scheduleActions('default'); + legacyAlert.setFlappingHistory([false, false, true, true]); + legacyAlert.setMaintenanceWindowIds(['maint-1', 'maint-321']); + + expect( + buildUpdatedRecoveredAlert<{}>({ + alert: existingFlattenedRecoveredAlert, + runTimestamp: '2030-12-15T02:44:13.124Z', + legacyRawAlert: { + meta: { + flapping: true, + flappingHistory: [false, false, true, true], + maintenanceWindowIds: ['maint-1', 'maint-321'], + }, + state: { + start: '3023-03-27T12:27:28.159Z', + }, + }, + rule: alertRule, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + ...alertRule, + [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2030-12-15T02:44:13.124Z', [EVENT_ACTION]: 'close', [EVENT_KIND]: 'signal', [ALERT_ACTION_GROUP]: 'recovered', @@ -132,6 +183,7 @@ describe('buildUpdatedRecoveredAlert', () => { version: '8.8.1', }, [TIMESTAMP]: '2023-03-29T12:27:28.159Z', + [ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z', [ALERT_FLAPPING]: true, [ALERT_FLAPPING_HISTORY]: [false, false, true, true], [ALERT_STATUS]: 'recovered', diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_updated_recovered_alert.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_updated_recovered_alert.ts index e02999eac950d..d393f5f513f6e 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/build_updated_recovered_alert.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_updated_recovered_alert.ts @@ -10,6 +10,7 @@ import type { Alert } from '@kbn/alerts-as-data-utils'; import { ALERT_FLAPPING, ALERT_FLAPPING_HISTORY, + ALERT_RULE_EXECUTION_TIMESTAMP, ALERT_RULE_EXECUTION_UUID, TIMESTAMP, } from '@kbn/rule-data-utils'; @@ -22,6 +23,7 @@ import { removeUnflattenedFieldsFromAlert, replaceRefreshableAlertFields } from interface BuildUpdatedRecoveredAlertOpts { alert: Alert & AlertData; legacyRawAlert: RawAlertInstance; + runTimestamp?: string; timestamp: string; rule: AlertRule; } @@ -35,6 +37,7 @@ export const buildUpdatedRecoveredAlert = ({ alert, legacyRawAlert, rule, + runTimestamp, timestamp, }: BuildUpdatedRecoveredAlertOpts): Alert & AlertData => { // Make sure that any alert fields that are updatable are flattened. @@ -45,6 +48,7 @@ export const buildUpdatedRecoveredAlert = ({ ...rule, // Update the timestamp to reflect latest update time [TIMESTAMP]: timestamp, + [ALERT_RULE_EXECUTION_TIMESTAMP]: runTimestamp ?? timestamp, // Set latest flapping state [ALERT_FLAPPING]: legacyRawAlert.meta?.flapping, // Set latest flapping history diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/initialize_alerts_client.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/initialize_alerts_client.test.ts index b81e369d6048d..779f286686a7f 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/initialize_alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/initialize_alerts_client.test.ts @@ -7,7 +7,6 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { - mockedRule, mockTaskInstance, ruleType, RULE_ID, @@ -18,11 +17,11 @@ import * as LegacyAlertsClientModule from '../legacy_alerts_client'; import { alertsServiceMock } from '../../alerts_service/alerts_service.mock'; import { ruleRunMetricsStoreMock } from '../../lib/rule_run_metrics_store.mock'; import { alertingEventLoggerMock } from '../../lib/alerting_event_logger/alerting_event_logger.mock'; -import { DEFAULT_FLAPPING_SETTINGS, DEFAULT_QUERY_DELAY_SETTINGS } from '../../types'; +import { DEFAULT_FLAPPING_SETTINGS } from '../../types'; import { alertsClientMock } from '../alerts_client.mock'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; import { legacyAlertsClientMock } from '../legacy_alerts_client.mock'; -import { initializeAlertsClient } from './initialize_alerts_client'; +import { initializeAlertsClient, RuleData } from './initialize_alerts_client'; const alertingEventLogger = alertingEventLoggerMock.create(); const ruleRunMetricsStore = ruleRunMetricsStoreMock.create(); @@ -51,8 +50,22 @@ const ruleTypeWithAlerts: jest.Mocked = { }, }; +const mockedRule: RuleData> = { + id: '1', + name: 'rule-name', + tags: ['rule-', '-tags'], + consumer: 'bar', + revision: 0, + params: { + bar: true, + }, +}; + +const mockedTaskInstance = mockTaskInstance(); + describe('initializeAlertsClient', () => { test('should initialize and return alertsClient if createAlertsClient succeeds', async () => { + const startedAt = new Date(Date.now() + 5 * 60 * 1000); const spy1 = jest .spyOn(LegacyAlertsClientModule, 'LegacyAlertsClient') .mockImplementation(() => legacyAlertsClient); @@ -62,7 +75,6 @@ describe('initializeAlertsClient', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -73,7 +85,61 @@ describe('initializeAlertsClient', () => { maxAlerts: 100, rule: mockedRule, ruleType: ruleTypeWithAlerts, - taskInstance: mockTaskInstance(), + startedAt, + taskInstance: mockedTaskInstance, + }); + + expect(alertsService.createAlertsClient).toHaveBeenCalledWith({ + logger, + ruleType: ruleTypeWithAlerts, + namespace: 'default', + rule: { + alertDelay: 0, + consumer: 'bar', + executionId: 'abc', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + expect(LegacyAlertsClientModule.LegacyAlertsClient).not.toHaveBeenCalled(); + expect(alertsClient.initializeExecution).toHaveBeenCalledWith({ + activeAlertsFromState: {}, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + maxAlerts: 100, + recoveredAlertsFromState: {}, + ruleLabel: `test:1: 'rule-name'`, + startedAt, + }); + spy1.mockRestore(); + }); + + test('should use DEFAULT_FLAPPING_SETTINGS if flappingSettings not defined', async () => { + const spy1 = jest + .spyOn(LegacyAlertsClientModule, 'LegacyAlertsClient') + .mockImplementation(() => legacyAlertsClient); + alertsService.createAlertsClient.mockImplementationOnce(() => alertsClient); + await initializeAlertsClient({ + alertsService, + context: { + alertingEventLogger, + ruleId: RULE_ID, + ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, + ruleRunMetricsStore, + spaceId: 'default', + }, + executionId: 'abc', + logger, + maxAlerts: 100, + rule: mockedRule, + ruleType: ruleTypeWithAlerts, + startedAt: mockedTaskInstance.startedAt, + taskInstance: mockedTaskInstance, }); expect(alertsService.createAlertsClient).toHaveBeenCalledWith({ @@ -116,7 +182,6 @@ describe('initializeAlertsClient', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -127,7 +192,8 @@ describe('initializeAlertsClient', () => { maxAlerts: 100, rule: mockedRule, ruleType: ruleTypeWithAlerts, - taskInstance: mockTaskInstance(), + startedAt: mockedTaskInstance.startedAt, + taskInstance: mockedTaskInstance, }); expect(alertsService.createAlertsClient).toHaveBeenCalledWith({ @@ -175,7 +241,6 @@ describe('initializeAlertsClient', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -186,7 +251,8 @@ describe('initializeAlertsClient', () => { maxAlerts: 100, rule: mockedRule, ruleType: ruleTypeWithAlerts, - taskInstance: mockTaskInstance(), + startedAt: mockedTaskInstance.startedAt, + taskInstance: mockedTaskInstance, }); expect(alertsService.createAlertsClient).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/initialize_alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/lib/initialize_alerts_client.ts index f00348ca502cb..f8c750c010501 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/initialize_alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/initialize_alerts_client.ts @@ -14,20 +14,28 @@ import { UntypedNormalizedRuleType } from '../../rule_type_registry'; import { AlertInstanceContext, AlertInstanceState, + DEFAULT_FLAPPING_SETTINGS, RuleAlertData, RuleTypeParams, SanitizedRule, } from '../../types'; import { RuleTaskInstance, RuleTypeRunnerContext } from '../../task_runner/types'; +export type RuleData = Pick< + SanitizedRule, + 'id' | 'name' | 'tags' | 'consumer' | 'revision' | 'alertDelay' | 'params' +>; + interface InitializeAlertsClientOpts { alertsService: AlertsService | null; context: RuleTypeRunnerContext; executionId: string; logger: Logger; maxAlerts: number; - rule: SanitizedRule; + rule: RuleData; ruleType: UntypedNormalizedRuleType; + runTimestamp?: Date; + startedAt: Date | null; taskInstance: RuleTaskInstance; } @@ -46,6 +54,8 @@ export const initializeAlertsClient = async < maxAlerts, rule, ruleType, + runTimestamp, + startedAt, taskInstance, }: InitializeAlertsClientOpts) => { const { @@ -106,8 +116,9 @@ export const initializeAlertsClient = async < await alertsClient.initializeExecution({ maxAlerts, ruleLabel: context.ruleLogPrefix, - flappingSettings: context.flappingSettings, - startedAt: taskInstance.startedAt!, + flappingSettings: context.flappingSettings ?? DEFAULT_FLAPPING_SETTINGS, + startedAt, + runTimestamp, activeAlertsFromState: alertRawInstances, recoveredAlertsFromState: alertRecoveredRawInstances, }); diff --git a/x-pack/plugins/alerting/server/alerts_client/types.ts b/x-pack/plugins/alerting/server/alerts_client/types.ts index 7f4f9ddf4d991..18fb52a806b62 100644 --- a/x-pack/plugins/alerting/server/alerts_client/types.ts +++ b/x-pack/plugins/alerting/server/alerts_client/types.ts @@ -130,6 +130,7 @@ export interface LogAlertsOpts { export interface InitializeExecutionOpts { maxAlerts: number; ruleLabel: string; + runTimestamp?: Date; startedAt: Date | null; flappingSettings: RulesSettingsFlappingProperties; activeAlertsFromState: Record; diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts b/x-pack/plugins/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts new file mode 100644 index 0000000000000..89ab626bed462 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/delete/delete_backfill.test.ts @@ -0,0 +1,278 @@ +/* + * 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 { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { RULE_SAVED_OBJECT_TYPE } from '../../../..'; +import { AlertingAuthorization } from '../../../../authorization'; +import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; +import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { + savedObjectsClientMock, + savedObjectsRepositoryMock, +} from '@kbn/core-saved-objects-api-server-mocks'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { ConstructorOptions, RulesClient } from '../../../../rules_client'; +import { adHocRunStatus } from '../../../../../common/constants'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { SavedObject } from '@kbn/core-saved-objects-api-server'; +import { AdHocRunSO } from '../../../../data/ad_hoc_run/types'; +import { SavedObjectsErrorHelpers } from '@kbn/core/server'; + +const kibanaVersion = 'v8.0.0'; +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const backfillClient = backfillClientMock.create(); +const logger = loggingSystemMock.create().get(); + +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger, + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + backfillClient, + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), +}; + +const mockAdHocRunSO: SavedObject = { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + apiKeyId: '123', + apiKeyToUse: 'MTIzOmFiYw==', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + // @ts-expect-error + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-10-19T15:07:40.011Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T03:07:40.011Z', + }, + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T15:07:40.011Z', + }, + ], + }, + references: [{ id: 'abc', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], +}; + +describe('deleteBackfill()', () => { + let rulesClient: RulesClient; + + beforeEach(async () => { + jest.resetAllMocks(); + rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.get.mockResolvedValue(mockAdHocRunSO); + unsecuredSavedObjectsClient.delete.mockResolvedValue({}); + }); + + test('should successfully delete backfill by id', async () => { + const result = await rulesClient.deleteBackfill('1'); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith(AD_HOC_RUN_SAVED_OBJECT_TYPE, '1'); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'myApp', + operation: 'deleteBackfill', + ruleTypeId: 'myType', + }); + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenCalledWith({ + event: { + action: 'ad_hoc_run_delete', + category: ['database'], + outcome: 'unknown', + type: ['deletion'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'User is deleting ad hoc run for ad_hoc_run_params [id=1]', + }); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenLastCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + '1' + ); + expect(taskManager.removeIfExists).toHaveBeenCalledWith('1'); + expect(logger.error).not.toHaveBeenCalled(); + + expect(result).toEqual({}); + }); + + describe('error handling', () => { + test('should retry if conflict error', async () => { + unsecuredSavedObjectsClient.delete.mockImplementationOnce(() => { + throw SavedObjectsErrorHelpers.createConflictError(AD_HOC_RUN_SAVED_OBJECT_TYPE, '1'); + }); + + const result = await rulesClient.deleteBackfill('1'); + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(2); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(2); + expect(taskManager.removeIfExists).toHaveBeenCalledTimes(1); + expect(result).toEqual({}); + }); + + test('should throw error when getting ad hoc run saved object throws error', async () => { + unsecuredSavedObjectsClient.get.mockImplementationOnce(() => { + throw new Error('error getting SO!'); + }); + await expect(rulesClient.deleteBackfill('1')).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to delete backfill by id: 1: error getting SO!"` + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed to delete backfill by id: 1 - Error: error getting SO!` + ); + }); + + test('should throw error when user does not have access to the rule being backfilled', async () => { + authorization.ensureAuthorized.mockImplementationOnce(() => { + throw new Error('no access for you'); + }); + await expect(rulesClient.deleteBackfill('1')).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to delete backfill by id: 1: no access for you"` + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed to delete backfill by id: 1 - Error: no access for you` + ); + + expect(auditLogger.log).toHaveBeenCalledWith({ + error: { code: 'Error', message: 'no access for you' }, + event: { + action: 'ad_hoc_run_delete', + category: ['database'], + outcome: 'failure', + type: ['deletion'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'Failed attempt to delete ad hoc run for ad_hoc_run_params [id=1]', + }); + }); + + test('should check for errors returned from saved objects client and throw', async () => { + // @ts-expect-error + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + error: { + error: 'my error', + message: 'Unable to get', + statusCode: 404, + }, + }); + + await expect(rulesClient.deleteBackfill('1')).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to delete backfill by id: 1: Unable to get"` + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed to delete backfill by id: 1 - Error: Unable to get` + ); + + expect(auditLogger.log).toHaveBeenCalledWith({ + error: { code: 'Error', message: 'Unable to get' }, + event: { + action: 'ad_hoc_run_delete', + category: ['database'], + outcome: 'failure', + type: ['deletion'], + }, + kibana: { saved_object: { id: '1', type: AD_HOC_RUN_SAVED_OBJECT_TYPE } }, + message: 'Failed attempt to delete ad hoc run for ad_hoc_run_params [id=1]', + }); + }); + + test('should throw error when deleting ad hoc run saved object throws error', async () => { + unsecuredSavedObjectsClient.delete.mockImplementationOnce(() => { + throw new Error('error deleting SO!'); + }); + await expect(rulesClient.deleteBackfill('1')).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to delete backfill by id: 1: error deleting SO!"` + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed to delete backfill by id: 1 - Error: error deleting SO!` + ); + }); + + test('should throw error when removing associated task throws error', async () => { + taskManager.removeIfExists.mockImplementationOnce(() => { + throw new Error('error removing task!'); + }); + await expect(rulesClient.deleteBackfill('1')).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to delete backfill by id: 1: error removing task!"` + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed to delete backfill by id: 1 - Error: error removing task!` + ); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/delete/delete_backfill.ts b/x-pack/plugins/alerting/server/application/backfill/methods/delete/delete_backfill.ts new file mode 100644 index 0000000000000..fe43e3555e8d3 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/delete/delete_backfill.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { retryIfConflicts } from '../../../../lib/retry_if_conflicts'; +import { AdHocRunSO } from '../../../../data/ad_hoc_run/types'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { RulesClientContext } from '../../../../rules_client'; +import { AlertingAuthorizationEntity, WriteOperations } from '../../../../authorization'; +import { + AdHocRunAuditAction, + adHocRunAuditEvent, +} from '../../../../rules_client/common/audit_events'; + +export async function deleteBackfill(context: RulesClientContext, id: string): Promise<{}> { + return await retryIfConflicts( + context.logger, + `rulesClient.deleteBackfill('${id}')`, + async () => await deleteWithOCC(context, { id }) + ); +} + +async function deleteWithOCC(context: RulesClientContext, { id }: { id: string }) { + try { + const result = await context.unsecuredSavedObjectsClient.get( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + id + ); + + // Check for errors in the savedObjectClient result + if (result.error) { + const err = new Error(result.error.message); + context.auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.DELETE, + savedObject: { type: AD_HOC_RUN_SAVED_OBJECT_TYPE, id }, + error: new Error(result.error.message), + }) + ); + throw err; + } + + try { + await context.authorization.ensureAuthorized({ + ruleTypeId: result.attributes.rule.alertTypeId, + consumer: result.attributes.rule.consumer, + operation: WriteOperations.DeleteBackfill, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + context.auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.DELETE, + savedObject: { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id, + name: `backfill for rule "${result.attributes.rule.name}"`, + }, + error, + }) + ); + throw error; + } + + context.auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.DELETE, + outcome: 'unknown', + savedObject: { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id, + name: `backfill for rule "${result.attributes.rule.name}"`, + }, + }) + ); + + // delete the saved object + const removeResult = await context.unsecuredSavedObjectsClient.delete( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + id + ); + + // remove the associated task + context.taskManager.removeIfExists(id); + + return removeResult; + } catch (err) { + const errorMessage = `Failed to delete backfill by id: ${id}`; + context.logger.error(`${errorMessage} - ${err}`); + throw Boom.boomify(err, { message: errorMessage }); + } +} diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/delete/index.ts b/x-pack/plugins/alerting/server/application/backfill/methods/delete/index.ts new file mode 100644 index 0000000000000..f560bebe0c42c --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/delete/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { deleteBackfill } from './delete_backfill'; diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/find/find_backfill.test.ts b/x-pack/plugins/alerting/server/application/backfill/methods/find/find_backfill.test.ts new file mode 100644 index 0000000000000..e7d4ab3644910 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/find/find_backfill.test.ts @@ -0,0 +1,785 @@ +/* + * 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 { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { RULE_SAVED_OBJECT_TYPE } from '../../../..'; +import { AlertingAuthorization } from '../../../../authorization'; +import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; +import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { + savedObjectsClientMock, + savedObjectsRepositoryMock, +} from '@kbn/core-saved-objects-api-server-mocks'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { fromKueryExpression } from '@kbn/es-query'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { ConstructorOptions, RulesClient } from '../../../../rules_client'; +import { adHocRunStatus } from '../../../../../common/constants'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { SavedObject } from '@kbn/core/server'; +import { AdHocRunSO } from '../../../../data/ad_hoc_run/types'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { transformAdHocRunToBackfillResult } from '../../transforms'; + +const kibanaVersion = 'v8.0.0'; +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const backfillClient = backfillClientMock.create(); + +const filter = fromKueryExpression( + '((ad_hoc_run_params.attributes.rule.alertTypeId:myType and ad_hoc_run_params.attributes.rule.consumer:myApp) or (ad_hoc_run_params.attributes.rule.alertTypeId:myOtherType and ad_hoc_run_params.attributes.rule.consumer:myApp) or (ad_hoc_run_params.attributes.rule.alertTypeId:myOtherType and ad_hoc_run_params.attributes.rule.consumer:myOtherApp))' +); + +const authDslFilter = { + arguments: [ + { + arguments: [ + { + arguments: [ + { + isQuoted: false, + type: 'literal', + value: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + { isQuoted: false, type: 'literal', value: 'myType' }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { + isQuoted: false, + type: 'literal', + value: 'ad_hoc_run_params.attributes.rule.consumer', + }, + { isQuoted: false, type: 'literal', value: 'myApp' }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'and', + type: 'function', + }, + { + arguments: [ + { + arguments: [ + { + isQuoted: false, + type: 'literal', + value: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + { isQuoted: false, type: 'literal', value: 'myOtherType' }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { + isQuoted: false, + type: 'literal', + value: 'ad_hoc_run_params.attributes.rule.consumer', + }, + { isQuoted: false, type: 'literal', value: 'myApp' }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'and', + type: 'function', + }, + { + arguments: [ + { + arguments: [ + { + isQuoted: false, + type: 'literal', + value: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + { isQuoted: false, type: 'literal', value: 'myOtherType' }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { + isQuoted: false, + type: 'literal', + value: 'ad_hoc_run_params.attributes.rule.consumer', + }, + { isQuoted: false, type: 'literal', value: 'myOtherApp' }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'and', + type: 'function', + }, + ], + function: 'or', + type: 'function', +}; + +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + backfillClient, + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), +}; + +const mockAdHocRunSO: SavedObject = { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + apiKeyId: '123', + apiKeyToUse: 'MTIzOmFiYw==', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + // @ts-expect-error + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-10-19T15:07:40.011Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T03:07:40.011Z', + }, + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T15:07:40.011Z', + }, + ], + }, + references: [{ id: 'abc', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], +}; + +describe('findBackfill()', () => { + let rulesClient: RulesClient; + + beforeEach(async () => { + jest.resetAllMocks(); + rulesClient = new RulesClient(rulesClientParams); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + filter, + ensureRuleTypeIsAuthorized() {}, + }); + unsecuredSavedObjectsClient.find.mockResolvedValue({ + saved_objects: [{ ...mockAdHocRunSO, score: 0 }], + per_page: 10, + page: 1, + total: 1, + }); + }); + + test('should successfully find backfill with no filter', async () => { + const result = await rulesClient.findBackfill({ page: 1, perPage: 10 }); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith('rule', { + fieldNames: { + consumer: 'ad_hoc_run_params.attributes.rule.consumer', + ruleTypeId: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + type: 'kql', + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + filter: authDslFilter, + page: 1, + perPage: 10, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + }); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_find', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'User has found ad hoc run for ad_hoc_run_params [id=1]', + }); + + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 1, + data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + }); + }); + + test('should successfully find backfill with rule id', async () => { + const result = await rulesClient.findBackfill({ page: 1, perPage: 10, ruleIds: 'abc' }); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith('rule', { + fieldNames: { + consumer: 'ad_hoc_run_params.attributes.rule.consumer', + ruleTypeId: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + type: 'kql', + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + filter: authDslFilter, + hasReference: [{ id: 'abc', type: RULE_SAVED_OBJECT_TYPE }], + page: 1, + perPage: 10, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + }); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_find', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'User has found ad hoc run for ad_hoc_run_params [id=1]', + }); + + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 1, + data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + }); + }); + + test('should successfully find backfill with start', async () => { + const result = await rulesClient.findBackfill({ + page: 1, + perPage: 10, + start: '2024-03-29T02:07:55Z', + }); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith('rule', { + fieldNames: { + consumer: 'ad_hoc_run_params.attributes.rule.consumer', + ruleTypeId: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + type: 'kql', + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + filter: { + type: 'function', + function: 'and', + arguments: [ + { + type: 'function', + function: 'range', + arguments: [ + { isQuoted: false, type: 'literal', value: 'ad_hoc_run_params.attributes.start' }, + 'gte', + { isQuoted: true, type: 'literal', value: '2024-03-29T02:07:55Z' }, + ], + }, + authDslFilter, + ], + }, + page: 1, + perPage: 10, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + }); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_find', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'User has found ad hoc run for ad_hoc_run_params [id=1]', + }); + + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 1, + data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + }); + }); + + test('should successfully find backfill with end', async () => { + const result = await rulesClient.findBackfill({ + page: 1, + perPage: 10, + end: '2024-03-29T02:07:55Z', + }); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith('rule', { + fieldNames: { + consumer: 'ad_hoc_run_params.attributes.rule.consumer', + ruleTypeId: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + type: 'kql', + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + filter: { + type: 'function', + function: 'and', + arguments: [ + { + type: 'function', + function: 'range', + arguments: [ + { isQuoted: false, type: 'literal', value: 'ad_hoc_run_params.attributes.end' }, + 'lte', + { isQuoted: true, type: 'literal', value: '2024-03-29T02:07:55Z' }, + ], + }, + authDslFilter, + ], + }, + page: 1, + perPage: 10, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + }); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_find', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'User has found ad hoc run for ad_hoc_run_params [id=1]', + }); + + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 1, + data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + }); + }); + + test('should successfully find backfill with start and end', async () => { + const result = await rulesClient.findBackfill({ + page: 1, + perPage: 10, + start: '2024-02-09T02:07:55Z', + end: '2024-03-29T02:07:55Z', + }); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith('rule', { + fieldNames: { + consumer: 'ad_hoc_run_params.attributes.rule.consumer', + ruleTypeId: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + type: 'kql', + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + filter: { + type: 'function', + function: 'and', + arguments: [ + { + type: 'function', + function: 'and', + arguments: [ + { + type: 'function', + function: 'range', + arguments: [ + { isQuoted: false, type: 'literal', value: 'ad_hoc_run_params.attributes.start' }, + 'gte', + { isQuoted: true, type: 'literal', value: '2024-02-09T02:07:55Z' }, + ], + }, + { + type: 'function', + function: 'range', + arguments: [ + { isQuoted: false, type: 'literal', value: 'ad_hoc_run_params.attributes.end' }, + 'lte', + { isQuoted: true, type: 'literal', value: '2024-03-29T02:07:55Z' }, + ], + }, + ], + }, + authDslFilter, + ], + }, + page: 1, + perPage: 10, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + }); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_find', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'User has found ad hoc run for ad_hoc_run_params [id=1]', + }); + + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 1, + data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + }); + }); + + test('should successfully find backfill with rule id, start and end', async () => { + const result = await rulesClient.findBackfill({ + page: 1, + perPage: 10, + start: '2024-02-09T02:07:55Z', + end: '2024-03-29T02:07:55Z', + ruleIds: 'abc', + }); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith('rule', { + fieldNames: { + consumer: 'ad_hoc_run_params.attributes.rule.consumer', + ruleTypeId: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + type: 'kql', + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + filter: { + type: 'function', + function: 'and', + arguments: [ + { + type: 'function', + function: 'and', + arguments: [ + { + type: 'function', + function: 'range', + arguments: [ + { isQuoted: false, type: 'literal', value: 'ad_hoc_run_params.attributes.start' }, + 'gte', + { isQuoted: true, type: 'literal', value: '2024-02-09T02:07:55Z' }, + ], + }, + { + type: 'function', + function: 'range', + arguments: [ + { isQuoted: false, type: 'literal', value: 'ad_hoc_run_params.attributes.end' }, + 'lte', + { isQuoted: true, type: 'literal', value: '2024-03-29T02:07:55Z' }, + ], + }, + ], + }, + authDslFilter, + ], + }, + hasReference: [{ id: 'abc', type: RULE_SAVED_OBJECT_TYPE }], + page: 1, + perPage: 10, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + }); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_find', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'User has found ad hoc run for ad_hoc_run_params [id=1]', + }); + + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 1, + data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + }); + }); + + test('should pass sort options to savedObjectsClient.find', async () => { + const result = await rulesClient.findBackfill({ + page: 1, + perPage: 10, + sortField: 'createdAt', + sortOrder: 'asc', + }); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith('rule', { + fieldNames: { + consumer: 'ad_hoc_run_params.attributes.rule.consumer', + ruleTypeId: 'ad_hoc_run_params.attributes.rule.alertTypeId', + }, + type: 'kql', + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + filter: authDslFilter, + page: 1, + perPage: 10, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + sortField: 'createdAt', + sortOrder: 'asc', + }); + + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_find', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'User has found ad hoc run for ad_hoc_run_params [id=1]', + }); + + expect(result).toEqual({ + page: 1, + perPage: 10, + total: 1, + data: [transformAdHocRunToBackfillResult(mockAdHocRunSO)], + }); + }); + + describe('error handling', () => { + test('should throw error when params are invalid', async () => { + await expect( + rulesClient.findBackfill({ + // @ts-expect-error + page: 'foo', + perPage: 10, + start: '2024-02-09T02:07:55Z', + end: '2024-03-29T02:07:55Z', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to find backfills: Could not validate find parameters \\"{\\"page\\":\\"foo\\",\\"perPage\\":10,\\"start\\":\\"2024-02-09T02:07:55Z\\",\\"end\\":\\"2024-03-29T02:07:55Z\\"}\\" - [page]: expected value of type [number] but got [string]"` + ); + await expect( + rulesClient.findBackfill({ + page: 1, + // @ts-expect-error + perPage: 'foo', + start: '2024-02-09T02:07:55Z', + end: '2024-03-29T02:07:55Z', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to find backfills: Could not validate find parameters \\"{\\"page\\":1,\\"perPage\\":\\"foo\\",\\"start\\":\\"2024-02-09T02:07:55Z\\",\\"end\\":\\"2024-03-29T02:07:55Z\\"}\\" - [perPage]: expected value of type [number] but got [string]"` + ); + await expect( + rulesClient.findBackfill({ + page: 1, + perPage: 10, + start: 'foo', + end: '2024-03-29T02:07:55Z', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to find backfills: Could not validate find parameters \\"{\\"page\\":1,\\"perPage\\":10,\\"start\\":\\"foo\\",\\"end\\":\\"2024-03-29T02:07:55Z\\"}\\" - [start]: query start must be valid date"` + ); + await expect( + rulesClient.findBackfill({ + page: 1, + perPage: 10, + end: 'foo', + start: '2024-03-29T02:07:55Z', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to find backfills: Could not validate find parameters \\"{\\"page\\":1,\\"perPage\\":10,\\"end\\":\\"foo\\",\\"start\\":\\"2024-03-29T02:07:55Z\\"}\\" - [end]: query end must be valid date"` + ); + await expect( + rulesClient.findBackfill({ + page: 1, + perPage: 10, + // @ts-expect-error + sortField: 'abc', + start: '2024-03-29T02:07:55Z', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` +"Failed to find backfills: Could not validate find parameters \\"{\\"page\\":1,\\"perPage\\":10,\\"sortField\\":\\"abc\\",\\"start\\":\\"2024-03-29T02:07:55Z\\"}\\" - [sortField]: types that failed validation: +- [sortField.0]: expected value to equal [createdAt] +- [sortField.1]: expected value to equal [start]" +` + ); + await expect( + rulesClient.findBackfill({ + page: 1, + perPage: 10, + // @ts-expect-error + sortOrder: 'abc', + start: '2024-03-29T02:07:55Z', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` +"Failed to find backfills: Could not validate find parameters \\"{\\"page\\":1,\\"perPage\\":10,\\"sortOrder\\":\\"abc\\",\\"start\\":\\"2024-03-29T02:07:55Z\\"}\\" - [sortOrder]: types that failed validation: +- [sortOrder.0]: expected value to equal [asc] +- [sortOrder.1]: expected value to equal [desc]" +` + ); + + expect(authorization.getFindAuthorizationFilter).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.find).not.toHaveBeenCalled(); + }); + + test('should throw error when getFindAuthorizationFilter throws error', async () => { + authorization.getFindAuthorizationFilter.mockImplementationOnce(() => { + throw new Error('error error'); + }); + await expect( + rulesClient.findBackfill({ + page: 1, + perPage: 10, + start: '2024-02-09T02:07:55Z', + end: '2024-03-29T02:07:55Z', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Failed to find backfills: error error"`); + + expect(auditLogger.log).toHaveBeenCalledWith({ + error: { code: 'Error', message: 'error error' }, + event: { + action: 'ad_hoc_run_find', + category: ['database'], + outcome: 'failure', + type: ['access'], + }, + kibana: { saved_object: undefined }, + message: 'Failed attempt to find ad hoc run for an ad hoc run', + }); + expect(unsecuredSavedObjectsClient.find).not.toHaveBeenCalled(); + }); + + test('should throw error when unsecuredSavedObjectsClient.find throws error', async () => { + unsecuredSavedObjectsClient.find.mockImplementationOnce(() => { + throw new Error('error finding'); + }); + await expect( + rulesClient.findBackfill({ + page: 1, + perPage: 10, + start: '2024-02-09T02:07:55Z', + end: '2024-03-29T02:07:55Z', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Failed to find backfills: error finding"`); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/find/find_backfill.ts b/x-pack/plugins/alerting/server/application/backfill/methods/find/find_backfill.ts new file mode 100644 index 0000000000000..522128d0385f8 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/find/find_backfill.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { KueryNode, nodeBuilder } from '@kbn/es-query'; +import { SavedObject, SavedObjectsFindOptionsReference } from '@kbn/core/server'; +import { buildKueryNodeFilter } from '../../../../rules_client/common'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { RulesClientContext } from '../../../../rules_client'; +import { + AlertingAuthorizationEntity, + AlertingAuthorizationFilterType, +} from '../../../../authorization'; +import { + adHocRunAuditEvent, + AdHocRunAuditAction, +} from '../../../../rules_client/common/audit_events'; +import type { FindBackfillParams, FindBackfillResult } from './types'; +import { findBackfillQuerySchema } from './schemas'; +import { AdHocRunSO } from '../../../../data/ad_hoc_run/types'; +import { transformAdHocRunToBackfillResult } from '../../transforms'; +import { Backfill } from '../../result/types'; + +export async function findBackfill( + context: RulesClientContext, + params: FindBackfillParams +): Promise { + try { + try { + findBackfillQuerySchema.validate(params); + } catch (error) { + throw new Error( + `Could not validate find parameters "${JSON.stringify(params)}" - ${error.message}` + ); + } + + let authorizationTuple; + try { + authorizationTuple = await context.authorization.getFindAuthorizationFilter( + AlertingAuthorizationEntity.Rule, + { + type: AlertingAuthorizationFilterType.KQL, + fieldNames: { + ruleTypeId: 'ad_hoc_run_params.attributes.rule.alertTypeId', + consumer: 'ad_hoc_run_params.attributes.rule.consumer', + }, + } + ); + } catch (error) { + context.auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.FIND, + error, + }) + ); + throw error; + } + + // Build options based on params + const hasReferenceArray: SavedObjectsFindOptionsReference[] = []; + if (params.ruleIds) { + const ruleIds = params.ruleIds.split(','); + (ruleIds ?? []).forEach((ruleId: string) => { + hasReferenceArray.push({ id: ruleId, type: RULE_SAVED_OBJECT_TYPE }); + }); + } + + const timeFilters: string[] = []; + if (params.start) { + timeFilters.push(`ad_hoc_run_params.attributes.start >= "${params.start}"`); + } + if (params.end) { + timeFilters.push(`ad_hoc_run_params.attributes.end <= "${params.end}"`); + } + const timeFilter = timeFilters.length > 0 ? timeFilters.join(` AND `) : undefined; + const filterKueryNode = buildKueryNodeFilter(timeFilter); + + const { filter: authorizationFilter } = authorizationTuple; + const { + page, + per_page: perPage, + total, + saved_objects: data, + } = await context.unsecuredSavedObjectsClient.find({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + page: params.page, + perPage: params.perPage, + filter: + (authorizationFilter && filterKueryNode + ? nodeBuilder.and([filterKueryNode, authorizationFilter as KueryNode]) + : authorizationFilter) ?? filterKueryNode, + ...(hasReferenceArray.length > 0 ? { hasReference: hasReferenceArray } : {}), + ...(params.sortField ? { sortField: params.sortField } : {}), + ...(params.sortOrder ? { sortOrder: params.sortOrder } : {}), + }); + + const transformedData: Backfill[] = data.map((so: SavedObject) => { + context.auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.FIND, + savedObject: { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: so.id, + name: `backfill for rule "${so.attributes.rule.name}"`, + }, + }) + ); + + return transformAdHocRunToBackfillResult(so) as Backfill; + }); + + return { + page, + perPage, + total, + data: transformedData, + }; + } catch (err) { + const errorMessage = `Failed to find backfills`; + context.logger.error(`${errorMessage} - ${err}`); + throw Boom.boomify(err, { message: errorMessage }); + } +} diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/find/index.ts b/x-pack/plugins/alerting/server/application/backfill/methods/find/index.ts new file mode 100644 index 0000000000000..0a991dd1cc624 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/find/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { findBackfill } from './find_backfill'; diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/find_backfill_query_schema.ts b/x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/find_backfill_query_schema.ts new file mode 100644 index 0000000000000..5aa8aabdc02f5 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/find_backfill_query_schema.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const findBackfillQuerySchema = schema.object( + { + end: schema.maybe(schema.string()), + page: schema.number({ defaultValue: 1, min: 1 }), + perPage: schema.number({ defaultValue: 10, min: 0 }), + ruleIds: schema.maybe(schema.string()), + start: schema.maybe(schema.string()), + sortField: schema.maybe(schema.oneOf([schema.literal('createdAt'), schema.literal('start')])), + sortOrder: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])), + }, + { + validate({ start, end }) { + if (start) { + const parsedStart = Date.parse(start); + if (isNaN(parsedStart)) { + return `[start]: query start must be valid date`; + } + } + if (end) { + const parsedEnd = Date.parse(end); + if (isNaN(parsedEnd)) { + return `[end]: query end must be valid date`; + } + } + }, + } +); diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/find_backfill_result_schema.ts b/x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/find_backfill_result_schema.ts new file mode 100644 index 0000000000000..b2e0e3ea71e9c --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/find_backfill_result_schema.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { backfillSchema } from '../../../result/schemas'; + +export const findBackfillResultSchema = schema.object({ + page: schema.number(), + perPage: schema.number(), + total: schema.number(), + data: schema.arrayOf(backfillSchema), +}); diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/index.ts b/x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/index.ts new file mode 100644 index 0000000000000..cd46247166ee1 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/find/schemas/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { findBackfillQuerySchema } from './find_backfill_query_schema'; +export { findBackfillResultSchema } from './find_backfill_result_schema'; diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/find/types/index.ts b/x-pack/plugins/alerting/server/application/backfill/methods/find/types/index.ts new file mode 100644 index 0000000000000..8d88732bcad0d --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/find/types/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { findBackfillQuerySchema, findBackfillResultSchema } from '../schemas'; + +export type FindBackfillParams = TypeOf; +export type FindBackfillResult = TypeOf; diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/get/get_backfill.test.ts b/x-pack/plugins/alerting/server/application/backfill/methods/get/get_backfill.test.ts new file mode 100644 index 0000000000000..952809acaa720 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/get/get_backfill.test.ts @@ -0,0 +1,236 @@ +/* + * 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 { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { RULE_SAVED_OBJECT_TYPE } from '../../../..'; +import { AlertingAuthorization } from '../../../../authorization'; +import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; +import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { + savedObjectsClientMock, + savedObjectsRepositoryMock, +} from '@kbn/core-saved-objects-api-server-mocks'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { ConstructorOptions, RulesClient } from '../../../../rules_client'; +import { adHocRunStatus } from '../../../../../common/constants'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { transformAdHocRunToBackfillResult } from '../../transforms'; +import { SavedObject } from '@kbn/core-saved-objects-api-server'; +import { AdHocRunSO } from '../../../../data/ad_hoc_run/types'; + +const kibanaVersion = 'v8.0.0'; +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const backfillClient = backfillClientMock.create(); +const logger = loggingSystemMock.create().get(); + +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger, + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + backfillClient, + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), +}; + +const mockAdHocRunSO: SavedObject = { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: { + apiKeyId: '123', + apiKeyToUse: 'MTIzOmFiYw==', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + // @ts-expect-error + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-10-19T15:07:40.011Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T03:07:40.011Z', + }, + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T15:07:40.011Z', + }, + ], + }, + references: [{ id: 'abc', name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], +}; + +describe('getBackfill()', () => { + let rulesClient: RulesClient; + + beforeEach(async () => { + jest.resetAllMocks(); + rulesClient = new RulesClient(rulesClientParams); + unsecuredSavedObjectsClient.get.mockResolvedValue(mockAdHocRunSO); + }); + + test('should successfully get backfill', async () => { + const result = await rulesClient.getBackfill('1'); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith(AD_HOC_RUN_SAVED_OBJECT_TYPE, '1'); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith({ + entity: 'rule', + consumer: 'myApp', + operation: 'getBackfill', + ruleTypeId: 'myType', + }); + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenCalledWith({ + event: { + action: 'ad_hoc_run_get', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'User has got ad hoc run for ad_hoc_run_params [id=1]', + }); + expect(logger.error).not.toHaveBeenCalled(); + + expect(result).toEqual(transformAdHocRunToBackfillResult(mockAdHocRunSO)); + }); + + describe('error handling', () => { + test('should throw error when getting ad hoc run saved object throws error', async () => { + unsecuredSavedObjectsClient.get.mockImplementationOnce(() => { + throw new Error('error getting SO!'); + }); + await expect(rulesClient.getBackfill('1')).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to get backfill by id: 1: error getting SO!"` + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed to get backfill by id: 1 - Error: error getting SO!` + ); + }); + + test('should throw error when user does not have access to the rule being backfilled', async () => { + authorization.ensureAuthorized.mockImplementationOnce(() => { + throw new Error('no access for you'); + }); + await expect(rulesClient.getBackfill('1')).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to get backfill by id: 1: no access for you"` + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed to get backfill by id: 1 - Error: no access for you` + ); + + expect(auditLogger.log).toHaveBeenCalledWith({ + error: { code: 'Error', message: 'no access for you' }, + event: { + action: 'ad_hoc_run_get', + category: ['database'], + outcome: 'failure', + type: ['access'], + }, + kibana: { + saved_object: { + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + name: `backfill for rule "my rule name"`, + }, + }, + message: 'Failed attempt to get ad hoc run for ad_hoc_run_params [id=1]', + }); + }); + + test('should check for errors returned from saved objects client and throw', async () => { + // @ts-expect-error + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + error: { + error: 'my error', + message: 'Unable to get', + statusCode: 404, + }, + }); + + await expect(rulesClient.getBackfill('1')).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to get backfill by id: 1: Unable to get"` + ); + expect(logger.error).toHaveBeenCalledWith( + `Failed to get backfill by id: 1 - Error: Unable to get` + ); + + expect(auditLogger.log).toHaveBeenCalledWith({ + error: { code: 'Error', message: 'Unable to get' }, + event: { + action: 'ad_hoc_run_get', + category: ['database'], + outcome: 'failure', + type: ['access'], + }, + kibana: { saved_object: { id: '1', type: AD_HOC_RUN_SAVED_OBJECT_TYPE } }, + message: 'Failed attempt to get ad hoc run for ad_hoc_run_params [id=1]', + }); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/get/get_backfill.ts b/x-pack/plugins/alerting/server/application/backfill/methods/get/get_backfill.ts new file mode 100644 index 0000000000000..75116ac0b3d2d --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/get/get_backfill.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { AdHocRunSO } from '../../../../data/ad_hoc_run/types'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { RulesClientContext } from '../../../../rules_client'; +import { ReadOperations, AlertingAuthorizationEntity } from '../../../../authorization'; +import { + AdHocRunAuditAction, + adHocRunAuditEvent, +} from '../../../../rules_client/common/audit_events'; +import { Backfill } from '../../result/types'; +import { transformAdHocRunToBackfillResult } from '../../transforms'; + +export async function getBackfill(context: RulesClientContext, id: string): Promise { + try { + const result = await context.unsecuredSavedObjectsClient.get( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + id + ); + + // Check for errors in the savedObjectClient result + if (result.error) { + const err = new Error(result.error.message); + context.auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.GET, + savedObject: { type: AD_HOC_RUN_SAVED_OBJECT_TYPE, id }, + error: new Error(result.error.message), + }) + ); + throw err; + } + + try { + await context.authorization.ensureAuthorized({ + ruleTypeId: result.attributes.rule.alertTypeId, + consumer: result.attributes.rule.consumer, + operation: ReadOperations.GetBackfill, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + context.auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.GET, + savedObject: { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id, + name: `backfill for rule "${result.attributes.rule.name}"`, + }, + error, + }) + ); + throw error; + } + context.auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.GET, + savedObject: { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id, + name: `backfill for rule "${result.attributes.rule.name}"`, + }, + }) + ); + + return transformAdHocRunToBackfillResult(result) as Backfill; + } catch (err) { + const errorMessage = `Failed to get backfill by id: ${id}`; + context.logger.error(`${errorMessage} - ${err}`); + throw Boom.boomify(err, { message: errorMessage }); + } +} diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/get/index.ts b/x-pack/plugins/alerting/server/application/backfill/methods/get/index.ts new file mode 100644 index 0000000000000..ecb3dbecae4f3 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/get/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getBackfill } from './get_backfill'; diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/schedule/index.ts b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/index.ts new file mode 100644 index 0000000000000..20dbcb1376d21 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { scheduleBackfill } from './schedule_backfill'; diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts new file mode 100644 index 0000000000000..c4944ba66daff --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts @@ -0,0 +1,604 @@ +/* + * 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 { ActionsAuthorization } from '@kbn/actions-plugin/server'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { RULE_SAVED_OBJECT_TYPE } from '../../../..'; +import { AlertingAuthorization } from '../../../../authorization'; +import { alertingAuthorizationMock } from '../../../../authorization/alerting_authorization.mock'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; +import { ruleTypeRegistryMock } from '../../../../rule_type_registry.mock'; +import { RecoveredActionGroup } from '@kbn/alerting-types'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { + savedObjectsClientMock, + savedObjectsRepositoryMock, +} from '@kbn/core-saved-objects-api-server-mocks'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { fromKueryExpression } from '@kbn/es-query'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { asyncForEach } from '@kbn/std'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { ConstructorOptions, RulesClient } from '../../../../rules_client'; +import { ScheduleBackfillParam } from './types'; +import { adHocRunStatus } from '../../../../../common/constants'; +import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; + +const kibanaVersion = 'v8.0.0'; +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const backfillClient = backfillClientMock.create(); + +const filter = fromKueryExpression( + '((alert.attributes.alertTypeId:myType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myOtherApp))' +); + +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: jest.fn(), + logger: loggingSystemMock.create().get(), + internalSavedObjectsRepository, + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + maxScheduledPerMinute: 10000, + minimumScheduleInterval: { value: '1m', enforce: false }, + isAuthenticationTypeAPIKey: jest.fn(), + getAuthenticationAPIKey: jest.fn(), + getAlertIndicesAlias: jest.fn(), + alertsService: null, + backfillClient, + isSystemAction: jest.fn(), + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + uiSettings: uiSettingsServiceMock.createStartContract(), +}; + +const existingRule = { + id: '1', + type: RULE_SAVED_OBJECT_TYPE, + attributes: { + enabled: false, + tags: ['foo'], + createdBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + legacyId: null, + muteAll: false, + mutedInstanceIds: [], + snoozeSchedule: [], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + executionStatus: { + lastExecutionDate: '2019-02-12T21:01:22.479Z', + status: 'pending', + }, + params: {}, + throttle: null, + notifyWhen: null, + actions: [], + systemActions: [], + name: 'my rule name', + revision: 0, + }, + references: [], + version: '123', +}; +const MOCK_API_KEY = Buffer.from('123:abc').toString('base64'); +const existingDecryptedRule1 = { + ...existingRule, + attributes: { + ...existingRule.attributes, + apiKey: MOCK_API_KEY, + apiKeyCreatedByUser: false, + }, +}; +const existingDecryptedRule2 = { + ...existingRule, + id: '2', + attributes: { + ...existingRule.attributes, + apiKey: MOCK_API_KEY, + apiKeyCreatedByUser: false, + }, +}; + +const mockBulkQueueResult = [ + { + ruleId: '1', + status: 'pending', + backfillId: 'abc', + backfillRuns: [ + { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T08:05:00.000Z', + status: adHocRunStatus.PENDING, + }, + { + start: '2023-11-16T08:05:00.000Z', + end: '2023-11-16T08:10:00.000Z', + status: adHocRunStatus.PENDING, + }, + ], + }, + { + ruleId: '2', + status: 'pending', + backfillId: 'def', + backfillRuns: [ + { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T08:05:00.000Z', + status: adHocRunStatus.PENDING, + }, + { + start: '2023-11-16T08:05:00.000Z', + end: '2023-11-16T08:10:00.000Z', + status: adHocRunStatus.PENDING, + }, + ], + }, +]; + +const mockCreatePointInTimeFinderAsInternalUser = ( + response = { saved_objects: [existingDecryptedRule1, existingDecryptedRule2] } +) => { + encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest + .fn() + .mockResolvedValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield response; + }, + }); +}; + +function getMockData(overwrites: Record = {}): ScheduleBackfillParam { + return { + ruleId: '1', + start: '2023-11-16T08:00:00.000Z', + ...overwrites, + }; +} + +describe('scheduleBackfill()', () => { + let rulesClient: RulesClient; + + beforeEach(async () => { + jest.resetAllMocks(); + rulesClient = new RulesClient(rulesClientParams); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + filter, + ensureRuleTypeIsAuthorized() {}, + }); + unsecuredSavedObjectsClient.find.mockResolvedValue({ + aggregations: { + alertTypeId: { + buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 1 }], + }, + }, + saved_objects: [], + per_page: 0, + page: 0, + total: 1, + }); + ruleTypeRegistry.get.mockReturnValue({ + id: 'myType', + name: 'Test', + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'custom', name: 'Not the Default' }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + async executor() { + return { state: {} }; + }, + category: 'test', + producer: 'alerts', + validate: { + params: { validate: (params) => params }, + }, + validLegacyConsumers: [], + autoRecoverAlerts: false, + }); + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...existingDecryptedRule1, + attributes: { ...existingDecryptedRule1.attributes, enabled: true }, + }, + { + ...existingDecryptedRule2, + attributes: { ...existingDecryptedRule2.attributes, enabled: true }, + }, + ], + }); + backfillClient.bulkQueue.mockResolvedValue(mockBulkQueueResult); + }); + + test('should successfully schedule backfill', async () => { + const mockData = [getMockData(), getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' })]; + const result = await rulesClient.scheduleBackfill(mockData); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledWith('rule', { + fieldNames: { + consumer: 'alert.attributes.consumer', + ruleTypeId: 'alert.attributes.alertTypeId', + }, + type: 'kql', + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + aggs: { + alertTypeId: { + multi_terms: { + terms: [ + { field: 'alert.attributes.alertTypeId' }, + { field: 'alert.attributes.consumer' }, + ], + }, + }, + }, + filter: { + arguments: [ + { + arguments: [ + { + arguments: [ + { isQuoted: false, type: 'literal', value: 'alert.id' }, + { isQuoted: false, type: 'literal', value: 'alert:1' }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { isQuoted: false, type: 'literal', value: 'alert.id' }, + { isQuoted: false, type: 'literal', value: 'alert:2' }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'or', + type: 'function', + }, + { + arguments: [ + { + arguments: [ + { + arguments: [ + { isQuoted: false, type: 'literal', value: 'alert.attributes.alertTypeId' }, + { isQuoted: false, type: 'literal', value: 'myType' }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { isQuoted: false, type: 'literal', value: 'alert.attributes.consumer' }, + { isQuoted: false, type: 'literal', value: 'myApp' }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'and', + type: 'function', + }, + { + arguments: [ + { + arguments: [ + { isQuoted: false, type: 'literal', value: 'alert.attributes.alertTypeId' }, + { isQuoted: false, type: 'literal', value: 'myOtherType' }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { isQuoted: false, type: 'literal', value: 'alert.attributes.consumer' }, + { isQuoted: false, type: 'literal', value: 'myApp' }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'and', + type: 'function', + }, + { + arguments: [ + { + arguments: [ + { isQuoted: false, type: 'literal', value: 'alert.attributes.alertTypeId' }, + { isQuoted: false, type: 'literal', value: 'myOtherType' }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { isQuoted: false, type: 'literal', value: 'alert.attributes.consumer' }, + { isQuoted: false, type: 'literal', value: 'myOtherApp' }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'and', + type: 'function', + }, + ], + function: 'or', + type: 'function', + }, + ], + function: 'and', + type: 'function', + }, + page: 1, + perPage: 0, + namespaces: ['default'], + type: 'alert', + }); + + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'rule_schedule_backfill', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { saved_object: { id: '1', type: RULE_SAVED_OBJECT_TYPE } }, + message: 'User has scheduled backfill for rule [id=1]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'rule_schedule_backfill', + category: ['database'], + outcome: 'success', + type: ['access'], + }, + kibana: { saved_object: { id: '2', type: RULE_SAVED_OBJECT_TYPE } }, + message: 'User has scheduled backfill for rule [id=2]', + }); + + expect(backfillClient.bulkQueue).toHaveBeenCalledWith({ + auditLogger, + params: mockData, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + spaceId: 'default', + rules: [ + { + id: existingDecryptedRule1.id, + actions: existingDecryptedRule1.attributes.actions, + alertTypeId: existingDecryptedRule1.attributes.alertTypeId, + apiKey: existingDecryptedRule1.attributes.apiKey, + apiKeyCreatedByUser: existingDecryptedRule1.attributes.apiKeyCreatedByUser, + consumer: existingDecryptedRule1.attributes.consumer, + createdAt: new Date(existingDecryptedRule1.attributes.createdAt), + createdBy: existingDecryptedRule1.attributes.createdBy, + enabled: true, + executionStatus: { + ...existingDecryptedRule1.attributes.executionStatus, + lastExecutionDate: new Date( + existingDecryptedRule1.attributes.executionStatus.lastExecutionDate + ), + }, + muteAll: existingDecryptedRule1.attributes.muteAll, + mutedInstanceIds: existingDecryptedRule1.attributes.mutedInstanceIds, + name: existingDecryptedRule1.attributes.name, + notifyWhen: existingDecryptedRule1.attributes.notifyWhen, + params: existingDecryptedRule1.attributes.params, + revision: existingDecryptedRule1.attributes.revision, + schedule: existingDecryptedRule1.attributes.schedule, + scheduledTaskId: existingDecryptedRule1.attributes.scheduledTaskId, + snoozeSchedule: existingDecryptedRule1.attributes.snoozeSchedule, + systemActions: existingDecryptedRule1.attributes.systemActions, + tags: existingDecryptedRule1.attributes.tags, + throttle: existingDecryptedRule1.attributes.throttle, + updatedAt: new Date(existingDecryptedRule1.attributes.updatedAt), + }, + { + id: existingDecryptedRule2.id, + actions: existingDecryptedRule2.attributes.actions, + alertTypeId: existingDecryptedRule2.attributes.alertTypeId, + apiKey: existingDecryptedRule2.attributes.apiKey, + apiKeyCreatedByUser: existingDecryptedRule2.attributes.apiKeyCreatedByUser, + consumer: existingDecryptedRule2.attributes.consumer, + createdAt: new Date(existingDecryptedRule2.attributes.createdAt), + createdBy: existingDecryptedRule2.attributes.createdBy, + enabled: true, + executionStatus: { + ...existingDecryptedRule2.attributes.executionStatus, + lastExecutionDate: new Date( + existingDecryptedRule2.attributes.executionStatus.lastExecutionDate + ), + }, + muteAll: existingDecryptedRule2.attributes.muteAll, + mutedInstanceIds: existingDecryptedRule2.attributes.mutedInstanceIds, + name: existingDecryptedRule2.attributes.name, + notifyWhen: existingDecryptedRule2.attributes.notifyWhen, + params: existingDecryptedRule2.attributes.params, + revision: existingDecryptedRule2.attributes.revision, + schedule: existingDecryptedRule2.attributes.schedule, + scheduledTaskId: existingDecryptedRule2.attributes.scheduledTaskId, + snoozeSchedule: existingDecryptedRule2.attributes.snoozeSchedule, + systemActions: existingDecryptedRule2.attributes.systemActions, + tags: existingDecryptedRule2.attributes.tags, + throttle: existingDecryptedRule2.attributes.throttle, + updatedAt: new Date(existingDecryptedRule2.attributes.updatedAt), + }, + ], + }); + expect(result).toEqual(mockBulkQueueResult); + }); + + describe('error handling', () => { + test('should throw error when params are invalid', async () => { + await expect( + // @ts-expect-error + rulesClient.scheduleBackfill(getMockData()) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error validating backfill schedule parameters \\"{\\"ruleId\\":\\"1\\",\\"start\\":\\"2023-11-16T08:00:00.000Z\\"}\\" - expected value of type [array] but got [Object]"` + ); + + await expect( + rulesClient.scheduleBackfill([getMockData({ ruleId: 1 })]) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error validating backfill schedule parameters \\"[{\\"ruleId\\":1,\\"start\\":\\"2023-11-16T08:00:00.000Z\\"}]\\" - [0.ruleId]: expected value of type [string] but got [number]"` + ); + }); + + test('should throw error when timestamps are invalid', async () => { + await expect( + rulesClient.scheduleBackfill([ + getMockData(), + getMockData({ + ruleId: '2', + start: '2023-11-17T08:00:00.000Z', + end: '2023-11-17T08:00:00.000Z', + }), + ]) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error validating backfill schedule parameters \\"[{\\"ruleId\\":\\"1\\",\\"start\\":\\"2023-11-16T08:00:00.000Z\\"},{\\"ruleId\\":\\"2\\",\\"start\\":\\"2023-11-17T08:00:00.000Z\\",\\"end\\":\\"2023-11-17T08:00:00.000Z\\"}]\\" - [1]: Backfill end must be greater than backfill start"` + ); + + await expect( + rulesClient.scheduleBackfill([ + getMockData(), + getMockData({ + ruleId: '2', + start: '2023-11-17T08:00:00.000Z', + end: '2023-11-16T08:00:00.000Z', + }), + ]) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Error validating backfill schedule parameters \\"[{\\"ruleId\\":\\"1\\",\\"start\\":\\"2023-11-16T08:00:00.000Z\\"},{\\"ruleId\\":\\"2\\",\\"start\\":\\"2023-11-17T08:00:00.000Z\\",\\"end\\":\\"2023-11-16T08:00:00.000Z\\"}]\\" - [1]: Backfill end must be greater than backfill start"` + ); + }); + + test('should throw error if user is not authorized to access rules', async () => { + authorization.getFindAuthorizationFilter.mockRejectedValueOnce(new Error('not authorized')); + await expect( + rulesClient.scheduleBackfill([getMockData()]) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"not authorized"`); + expect(auditLogger.log).toHaveBeenCalledWith({ + error: { code: 'Error', message: 'not authorized' }, + event: { + action: 'rule_schedule_backfill', + category: ['database'], + outcome: 'failure', + type: ['access'], + }, + kibana: { saved_object: undefined }, + message: 'Failed attempt to schedule backfill for a rule', + }); + }); + + test('should throw error if no rules found for scheduling', async () => { + await asyncForEach( + [{}, { alertTypeId: {} }, { alertTypeId: { buckets: [] } }], + async (aggregations) => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + aggregations, + saved_objects: [], + per_page: 0, + page: 0, + total: 1, + }); + await expect( + rulesClient.scheduleBackfill([getMockData()]) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"No rules matching ids 1 found to schedule backfill"` + ); + } + ); + }); + + test('should throw error if any scheduled rule types are disabled', async () => { + const mockData = [ + getMockData(), + getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' }), + ]; + ruleTypeRegistry.ensureRuleTypeEnabled.mockImplementationOnce(() => { + throw new Error('Not enabled'); + }); + + await expect( + rulesClient.scheduleBackfill(mockData) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Not enabled"`); + }); + + test('should throw error if any scheduled rule types are not authorized for this user', async () => { + const mockData = [ + getMockData(), + getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' }), + ]; + authorization.ensureAuthorized.mockImplementationOnce(() => { + throw new Error('Unauthorized'); + }); + + await expect( + rulesClient.scheduleBackfill(mockData) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Unauthorized"`); + expect(auditLogger?.log).toHaveBeenCalledWith({ + error: { code: 'Error', message: 'Unauthorized' }, + event: { + action: 'rule_schedule_backfill', + category: ['database'], + outcome: 'failure', + type: ['access'], + }, + kibana: { saved_object: undefined }, + message: 'Failed attempt to schedule backfill for a rule', + }); + }); + + test('should throw if error bulk scheduling backfill tasks', async () => { + const mockData = [ + getMockData(), + getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' }), + ]; + backfillClient.bulkQueue.mockImplementationOnce(() => { + throw new Error('error bulk queuing!'); + }); + await expect( + rulesClient.scheduleBackfill(mockData) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"error bulk queuing!"`); + + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalled(); + expect(backfillClient.bulkQueue).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts new file mode 100644 index 0000000000000..9ff777f0108c6 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts @@ -0,0 +1,164 @@ +/* + * 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 pMap from 'p-map'; +import Boom from '@hapi/boom'; +import { KueryNode, nodeBuilder } from '@kbn/es-query'; +import { SavedObjectsFindResult } from '@kbn/core/server'; +import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { RuleAttributes } from '../../../../data/rule/types'; +import { findRulesSo } from '../../../../data/rule'; +import { + alertingAuthorizationFilterOpts, + RULE_TYPE_CHECKS_CONCURRENCY, +} from '../../../../rules_client/common/constants'; +import { convertRuleIdsToKueryNode } from '../../../../lib'; +import { RuleBulkOperationAggregation, RulesClientContext } from '../../../../rules_client'; +import { AlertingAuthorizationEntity, WriteOperations } from '../../../../authorization'; +import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events'; +import type { + ScheduleBackfillParam, + ScheduleBackfillParams, + ScheduleBackfillResults, +} from './types'; +import { scheduleBackfillParamsSchema } from './schemas'; +import { transformRuleAttributesToRuleDomain } from '../../../rule/transforms'; + +export async function scheduleBackfill( + context: RulesClientContext, + params: ScheduleBackfillParams +): Promise { + try { + scheduleBackfillParamsSchema.validate(params); + } catch (error) { + throw Boom.badRequest( + `Error validating backfill schedule parameters "${JSON.stringify(params)}" - ${error.message}` + ); + } + + // Get rule SO IDs + const ruleIds = params.map((param: ScheduleBackfillParam) => param.ruleId); + const kueryNodeFilter = convertRuleIdsToKueryNode(ruleIds); + + let authorizationTuple; + try { + authorizationTuple = await context.authorization.getFindAuthorizationFilter( + AlertingAuthorizationEntity.Rule, + alertingAuthorizationFilterOpts + ); + } catch (error) { + context.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.SCHEDULE_BACKFILL, + error, + }) + ); + throw error; + } + const { filter: authorizationFilter } = authorizationTuple; + const kueryNodeFilterWithAuth = + authorizationFilter && kueryNodeFilter + ? nodeBuilder.and([kueryNodeFilter, authorizationFilter as KueryNode]) + : kueryNodeFilter; + + const { aggregations } = await findRulesSo({ + savedObjectsClient: context.unsecuredSavedObjectsClient, + savedObjectsFindOptions: { + filter: kueryNodeFilterWithAuth, + page: 1, + perPage: 0, + ...(context.namespace ? { namespaces: [context.namespace] } : undefined), + aggs: { + alertTypeId: { + multi_terms: { + terms: [ + { field: 'alert.attributes.alertTypeId' }, + { field: 'alert.attributes.consumer' }, + ], + }, + }, + }, + }, + }); + + const buckets = aggregations?.alertTypeId?.buckets; + if (buckets === undefined || !buckets.length) { + throw Boom.badRequest(`No rules matching ids ${ruleIds} found to schedule backfill`); + } + + await pMap( + buckets, + async ({ key: [ruleType, consumer] }) => { + context.ruleTypeRegistry.ensureRuleTypeEnabled(ruleType); + + try { + await context.authorization.ensureAuthorized({ + ruleTypeId: ruleType, + consumer, + operation: WriteOperations.ScheduleBackfill, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + context.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.SCHEDULE_BACKFILL, + error, + }) + ); + throw error; + } + }, + { concurrency: RULE_TYPE_CHECKS_CONCURRENCY } + ); + + const rulesFinder = + await context.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser( + { + filter: kueryNodeFilterWithAuth, + type: RULE_SAVED_OBJECT_TYPE, + perPage: 100, + ...(context.namespace ? { namespaces: [context.namespace] } : undefined), + } + ); + + let rulesToSchedule: Array> = []; + for await (const response of rulesFinder.find()) { + for (const rule of response.saved_objects) { + context.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.SCHEDULE_BACKFILL, + savedObject: { type: RULE_SAVED_OBJECT_TYPE, id: rule.id }, + }) + ); + } + + rulesToSchedule = [...response.saved_objects]; + } + + const actionsClient = await context.getActionsClient(); + return await context.backfillClient.bulkQueue({ + auditLogger: context.auditLogger, + params, + rules: rulesToSchedule.map(({ id, attributes, references }) => { + const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId!); + return transformRuleAttributesToRuleDomain( + attributes as RuleAttributes, + { + id, + logger: context.logger, + ruleType, + references, + omitGeneratedValues: false, + }, + (connectorId: string) => actionsClient.isSystemAction(connectorId) + ); + }), + ruleTypeRegistry: context.ruleTypeRegistry, + spaceId: context.spaceId, + unsecuredSavedObjectsClient: context.unsecuredSavedObjectsClient, + }); +} diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/index.ts b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/index.ts new file mode 100644 index 0000000000000..7fe9accc9b403 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + scheduleBackfillParamSchema, + scheduleBackfillParamsSchema, +} from './schedule_backfill_params_schema'; +export { + scheduleBackfillErrorSchema, + scheduleBackfillResultSchema, + scheduleBackfillResultsSchema, +} from './schedule_backfill_result_schema'; diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_params_schema.ts b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_params_schema.ts new file mode 100644 index 0000000000000..0082181e759a6 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_params_schema.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { MAX_SCHEDULE_BACKFILL_BULK_SIZE } from '../../../../../../common/constants'; + +export const scheduleBackfillParamSchema = schema.object( + { + ruleId: schema.string(), + start: schema.string(), + end: schema.maybe(schema.string()), + }, + { + validate({ start, end }) { + const parsedStart = Date.parse(start); + if (isNaN(parsedStart)) { + return `Backfill start must be valid date`; + } + + if (end) { + const parsedEnd = Date.parse(end); + if (isNaN(parsedEnd)) { + return `Backfill end must be valid date`; + } + const startMs = new Date(start).valueOf(); + const endMs = new Date(end).valueOf(); + if (endMs <= startMs) { + return `Backfill end must be greater than backfill start`; + } + } + }, + } +); + +export const scheduleBackfillParamsSchema = schema.arrayOf(scheduleBackfillParamSchema, { + minSize: 1, + maxSize: MAX_SCHEDULE_BACKFILL_BULK_SIZE, +}); diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_result_schema.ts b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_result_schema.ts new file mode 100644 index 0000000000000..023ab90c0ee75 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schemas/schedule_backfill_result_schema.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { backfillSchema } from '../../../result/schemas'; + +export const scheduleBackfillErrorSchema = schema.object({ + error: schema.object({ + error: schema.string(), + message: schema.string(), + }), +}); + +export const scheduleBackfillResultSchema = schema.oneOf([ + backfillSchema, + scheduleBackfillErrorSchema, +]); +export const scheduleBackfillResultsSchema = schema.arrayOf(scheduleBackfillResultSchema); diff --git a/x-pack/plugins/alerting/server/application/backfill/methods/schedule/types/index.ts b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/types/index.ts new file mode 100644 index 0000000000000..bd7f3e4e371ae --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/types/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { + scheduleBackfillErrorSchema, + scheduleBackfillParamSchema, + scheduleBackfillParamsSchema, + scheduleBackfillResultSchema, + scheduleBackfillResultsSchema, +} from '../schemas'; + +export type ScheduleBackfillParam = TypeOf; +export type ScheduleBackfillParams = TypeOf; +export type ScheduleBackfillResult = TypeOf; +export type ScheduleBackfillResults = TypeOf; +export type ScheduleBackfillError = TypeOf; diff --git a/x-pack/plugins/alerting/server/application/backfill/result/schemas/index.ts b/x-pack/plugins/alerting/server/application/backfill/result/schemas/index.ts new file mode 100644 index 0000000000000..de3cc5926a4ae --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/result/schemas/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { adHocRunStatus } from '../../../../../common/constants'; + +export const statusSchema = schema.oneOf([ + schema.literal(adHocRunStatus.COMPLETE), + schema.literal(adHocRunStatus.PENDING), + schema.literal(adHocRunStatus.RUNNING), + schema.literal(adHocRunStatus.ERROR), + schema.literal(adHocRunStatus.TIMEOUT), +]); + +export const backfillScheduleSchema = schema.object({ + runAt: schema.string(), + status: statusSchema, + interval: schema.string(), +}); + +export const backfillSchema = schema.object({ + id: schema.string(), + createdAt: schema.string(), + duration: schema.string(), + enabled: schema.boolean(), + rule: schema.object({ + id: schema.string(), + name: schema.string(), + tags: schema.arrayOf(schema.string()), + alertTypeId: schema.string(), + params: schema.recordOf(schema.string(), schema.maybe(schema.any())), + apiKeyOwner: schema.nullable(schema.string()), + apiKeyCreatedByUser: schema.maybe(schema.nullable(schema.boolean())), + consumer: schema.string(), + enabled: schema.boolean(), + schedule: schema.object({ interval: schema.string() }), + createdBy: schema.nullable(schema.string()), + updatedBy: schema.nullable(schema.string()), + createdAt: schema.string(), + updatedAt: schema.string(), + revision: schema.number(), + }), + spaceId: schema.string(), + start: schema.string(), + status: statusSchema, + end: schema.maybe(schema.string()), + schedule: schema.arrayOf(backfillScheduleSchema), +}); diff --git a/x-pack/plugins/alerting/server/application/backfill/result/types/index.ts b/x-pack/plugins/alerting/server/application/backfill/result/types/index.ts new file mode 100644 index 0000000000000..f62663894ca89 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/result/types/index.ts @@ -0,0 +1,12 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { backfillSchema, backfillScheduleSchema } from '../schemas'; + +export type BackfillSchedule = TypeOf; +export type Backfill = TypeOf; diff --git a/x-pack/plugins/alerting/server/application/backfill/transforms/index.ts b/x-pack/plugins/alerting/server/application/backfill/transforms/index.ts new file mode 100644 index 0000000000000..0b14236e745d2 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/transforms/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformBackfillParamToAdHocRun } from './transform_backfill_param_to_ad_hoc_run'; +export { transformAdHocRunToBackfillResult } from './transform_ad_hoc_run_to_backfill_result'; diff --git a/x-pack/plugins/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.test.ts b/x-pack/plugins/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.test.ts new file mode 100644 index 0000000000000..35c2568a8074a --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.test.ts @@ -0,0 +1,251 @@ +/* + * 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 { AdHocRunSO } from '../../../data/ad_hoc_run/types'; +import { SavedObject } from '@kbn/core/server'; +import { adHocRunStatus } from '../../../../common/constants'; +import { transformAdHocRunToBackfillResult } from './transform_ad_hoc_run_to_backfill_result'; + +function getMockAdHocRunAttributes({ + ruleId, + overwrites, + omitApiKey = false, +}: { + ruleId?: string; + overwrites?: Record; + omitApiKey?: boolean; +} = {}): AdHocRunSO { + return { + ...(omitApiKey ? {} : { apiKeyId: '123', apiKeyToUse: 'MTIzOmFiYw==' }), + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + ...(ruleId ? { id: ruleId } : {}), + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + // @ts-expect-error + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-10-19T15:07:40.011Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T03:07:40.011Z', + }, + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T15:07:40.011Z', + }, + ], + ...overwrites, + }; +} + +function getBulkCreateResponse( + id: string, + ruleId: string, + attributes: AdHocRunSO +): SavedObject { + return { + type: 'ad_hoc_rule_run_params', + id, + namespaces: ['default'], + attributes, + references: [ + { + id: ruleId, + name: 'rule', + type: 'alert', + }, + ], + managed: false, + coreMigrationVersion: '8.8.0', + updated_at: '2024-02-07T16:05:39.296Z', + created_at: '2024-02-07T16:05:39.296Z', + version: 'WzcsMV0=', + }; +} + +describe('transformAdHocRunToBackfillResult', () => { + test('should transform bulk create response', () => { + expect( + transformAdHocRunToBackfillResult( + getBulkCreateResponse('abc', '1', getMockAdHocRunAttributes()) + ) + ).toEqual({ + id: 'abc', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + id: '1', + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-10-19T15:07:40.011Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T03:07:40.011Z', + }, + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T15:07:40.011Z', + }, + ], + }); + }); + + test('should return error for malformed responses', () => { + expect( + transformAdHocRunToBackfillResult( + // missing id + // @ts-expect-error + { + type: 'ad_hoc_rule_run_params', + namespaces: ['default'], + attributes: getMockAdHocRunAttributes(), + references: [{ id: '1', name: 'rule', type: 'alert' }], + managed: false, + coreMigrationVersion: '8.8.0', + updated_at: '2024-02-07T16:05:39.296Z', + created_at: '2024-02-07T16:05:39.296Z', + version: 'WzcsMV0=', + } + ) + ).toEqual({ + error: { + error: 'Internal Server Error', + message: 'Malformed saved object in bulkCreate response - Missing "id".', + }, + }); + expect( + transformAdHocRunToBackfillResult( + // missing attributes + // @ts-expect-error + { + type: 'ad_hoc_rule_run_params', + id: 'abc', + namespaces: ['default'], + references: [{ id: '1', name: 'rule', type: 'alert' }], + managed: false, + coreMigrationVersion: '8.8.0', + updated_at: '2024-02-07T16:05:39.296Z', + created_at: '2024-02-07T16:05:39.296Z', + version: 'WzcsMV0=', + } + ) + ).toEqual({ + error: { + error: 'Internal Server Error', + message: 'Malformed saved object in bulkCreate response - Missing "attributes".', + }, + }); + expect( + transformAdHocRunToBackfillResult( + // missing references + // @ts-expect-error + { + type: 'ad_hoc_rule_run_params', + id: 'def', + namespaces: ['default'], + attributes: getMockAdHocRunAttributes(), + managed: false, + coreMigrationVersion: '8.8.0', + updated_at: '2024-02-07T16:05:39.296Z', + created_at: '2024-02-07T16:05:39.296Z', + version: 'WzcsMV0=', + } + ) + ).toEqual({ + error: { + error: 'Internal Server Error', + message: 'Malformed saved object in bulkCreate response - Missing "references".', + }, + }); + expect( + transformAdHocRunToBackfillResult( + // empty references + { + type: 'ad_hoc_rule_run_params', + id: 'ghi', + namespaces: ['default'], + attributes: getMockAdHocRunAttributes(), + references: [], + managed: false, + coreMigrationVersion: '8.8.0', + updated_at: '2024-02-07T16:05:39.296Z', + created_at: '2024-02-07T16:05:39.296Z', + version: 'WzcsMV0=', + } + ) + ).toEqual({ + error: { + error: 'Internal Server Error', + message: 'Malformed saved object in bulkCreate response - Missing "references".', + }, + }); + }); + + test('should pass through error if saved object error', () => { + expect( + transformAdHocRunToBackfillResult( + // @ts-expect-error + { + type: 'ad_hoc_rule_run_params', + id: '788a2784-c021-484f-a53e-0c1c63c7567c', + error: { + error: 'my error', + message: 'Unable to create', + statusCode: 404, + }, + } + ) + ).toEqual({ + error: { + error: 'my error', + message: 'Unable to create', + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.ts b/x-pack/plugins/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.ts new file mode 100644 index 0000000000000..219587bd0f1dd --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/transforms/transform_ad_hoc_run_to_backfill_result.ts @@ -0,0 +1,60 @@ +/* + * 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 { SavedObject } from '@kbn/core/server'; +import { AdHocRunSO } from '../../../data/ad_hoc_run/types'; +import { createBackfillError } from '../../../backfill_client/lib'; +import { ScheduleBackfillResult } from '../methods/schedule/types'; + +export const transformAdHocRunToBackfillResult = ({ + id, + attributes, + references, + error, +}: SavedObject): ScheduleBackfillResult => { + if (error) { + return createBackfillError(error.error, error.message); + } + + if (!id) { + return createBackfillError( + 'Internal Server Error', + 'Malformed saved object in bulkCreate response - Missing "id".' + ); + } + + if (!attributes) { + return createBackfillError( + 'Internal Server Error', + 'Malformed saved object in bulkCreate response - Missing "attributes".' + ); + } + + if (!references || !references.length) { + return createBackfillError( + 'Internal Server Error', + 'Malformed saved object in bulkCreate response - Missing "references".' + ); + } + + return { + id, + // exclude API key information + createdAt: attributes.createdAt, + duration: attributes.duration, + enabled: attributes.enabled, + ...(attributes.end ? { end: attributes.end } : {}), + rule: { + ...attributes.rule, + id: references[0].id, + }, + spaceId: attributes.spaceId, + start: attributes.start, + status: attributes.status, + schedule: attributes.schedule, + }; +}; diff --git a/x-pack/plugins/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.test.ts b/x-pack/plugins/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.test.ts new file mode 100644 index 0000000000000..0dd1995e05b98 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.test.ts @@ -0,0 +1,154 @@ +/* + * 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 { adHocRunStatus } from '../../../../common/constants'; +import { RuleDomain } from '../../rule/types'; +import { ScheduleBackfillParam } from '../methods/schedule/types'; +import { transformBackfillParamToAdHocRun } from './transform_backfill_param_to_ad_hoc_run'; + +function getMockData(overwrites: Record = {}): ScheduleBackfillParam { + return { + ruleId: '1', + start: '2023-11-16T08:00:00.000Z', + ...overwrites, + }; +} + +const MOCK_API_KEY = Buffer.from('123:abc').toString('base64'); +function getMockRule(overwrites: Record = {}): RuleDomain { + return { + id: '1', + actions: [], + alertTypeId: 'myType', + apiKey: MOCK_API_KEY, + apiKeyCreatedByUser: false, + apiKeyOwner: 'user', + consumer: 'myApp', + createdAt: new Date('2019-02-12T21:01:22.479Z'), + createdBy: 'user', + enabled: true, + executionStatus: { + lastExecutionDate: new Date('2019-02-12T21:01:22.479Z'), + status: 'pending', + }, + muteAll: false, + mutedInstanceIds: [], + name: 'my rule name', + notifyWhen: null, + // @ts-expect-error + params: {}, + revision: 0, + schedule: { interval: '12h' }, + scheduledTaskId: 'task-123', + snoozeSchedule: [], + tags: ['foo'], + throttle: null, + updatedAt: new Date('2019-02-12T21:01:22.479Z'), + updatedBy: 'user', + ...overwrites, + }; +} + +describe('transformBackfillParamToAdHocRun', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2024-01-30T00:00:00.000Z')); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should transform backfill param with start', () => { + expect(transformBackfillParamToAdHocRun(getMockData(), getMockRule(), 'default')).toEqual({ + apiKeyId: '123', + apiKeyToUse: 'MTIzOmFiYw==', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + // injects end parameter + end: '2023-11-16T20:00:00.000Z', + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-11-16T08:00:00.000Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }); + }); + + test('should transform backfill param with start and end', () => { + expect( + transformBackfillParamToAdHocRun( + getMockData({ end: '2023-11-17T08:00:00.000Z' }), + getMockRule(), + 'default' + ) + ).toEqual({ + apiKeyId: '123', + apiKeyToUse: 'MTIzOmFiYw==', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + end: '2023-11-17T08:00:00.000Z', + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-11-16T08:00:00.000Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + { + runAt: '2023-11-17T08:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.ts b/x-pack/plugins/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.ts new file mode 100644 index 0000000000000..4dc01a6c8939e --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.ts @@ -0,0 +1,49 @@ +/* + * 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 { isString } from 'lodash'; +import { AdHocRunSO } from '../../../data/ad_hoc_run/types'; +import { calculateSchedule } from '../../../backfill_client/lib'; +import { adHocRunStatus } from '../../../../common/constants'; +import { RuleDomain } from '../../rule/types'; +import { ScheduleBackfillParam } from '../methods/schedule/types'; + +export const transformBackfillParamToAdHocRun = ( + param: ScheduleBackfillParam, + rule: RuleDomain, + spaceId: string +): AdHocRunSO => { + const schedule = calculateSchedule(param.start, rule.schedule.interval, param.end); + return { + apiKeyId: Buffer.from(rule.apiKey!, 'base64').toString().split(':')[0], + apiKeyToUse: rule.apiKey!, + createdAt: new Date().toISOString(), + duration: rule.schedule.interval, + enabled: true, + end: param.end ? param.end : schedule && schedule.length > 0 ? schedule[0].runAt : undefined, + rule: { + name: rule.name, + tags: rule.tags, + alertTypeId: rule.alertTypeId, + params: rule.params, + apiKeyOwner: rule.apiKeyOwner, + apiKeyCreatedByUser: rule.apiKeyCreatedByUser, + consumer: rule.consumer, + enabled: rule.enabled, + schedule: rule.schedule, + createdBy: rule.createdBy, + updatedBy: rule.updatedBy, + createdAt: isString(rule.createdAt) ? rule.createdAt : rule.createdAt.toISOString(), + updatedAt: isString(rule.updatedAt) ? rule.updatedAt : rule.updatedAt.toISOString(), + revision: rule.revision, + }, + spaceId, + start: param.start, + status: adHocRunStatus.PENDING, + schedule, + }; +}; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/aggregate/aggregate_rules.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/aggregate/aggregate_rules.test.ts index 858bc6ded1cb3..57fb2bf8f48a4 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/aggregate/aggregate_rules.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/aggregate/aggregate_rules.test.ts @@ -29,6 +29,7 @@ import { DefaultRuleAggregationResult } from '../../../../routes/rule/apis/aggre import { defaultRuleAggregationFactory } from '.'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -64,6 +65,7 @@ const rulesClientParams: jest.Mocked = { alertsService: null, maxScheduledPerMinute: 1000, internalSavedObjectsRepository, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts index 6ccd32879a171..143e84dc24b64 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_delete/bulk_delete_rules.test.ts @@ -41,6 +41,7 @@ import { import { migrateLegacyActions } from '../../../../rules_client/lib'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -94,6 +95,7 @@ const rulesClientParams: jest.Mocked = { isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts index be64257c86f8c..77ebddbf8c2dd 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_disable/bulk_disable_rules.test.ts @@ -46,6 +46,7 @@ import { migrateLegacyActions } from '../../../../rules_client/lib'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { ActionsClient } from '@kbn/actions-plugin/server'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -106,6 +107,7 @@ const rulesClientParams: jest.Mocked = { isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts index 771254755431c..f43ffc6096dcf 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_edit/bulk_edit_rules.test.ts @@ -41,6 +41,7 @@ import { RuleAttributes } from '../../../../data/rule/types'; import { SavedObject } from '@kbn/core/server'; import { bulkEditOperationsSchema } from './schemas'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -112,6 +113,7 @@ const rulesClientParams: jest.Mocked = { isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; const paramsModifier = jest.fn(); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts index f2de0ed7840dd..4e2077f15a899 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/bulk_untrack/bulk_untrack_alerts.test.ts @@ -22,6 +22,7 @@ import { AlertingAuthorization } from '../../../../authorization/alerting_author import { alertsServiceMock } from '../../../../alerts_service/alerts_service.mock'; import { ALERT_RULE_UUID, ALERT_UUID } from '@kbn/rule-data-utils'; import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; const taskManager = taskManagerMock.createStart(); @@ -61,6 +62,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts index 50f5abb5d3d73..a2f1c0dfa92f1 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/create_rule.test.ts @@ -33,6 +33,7 @@ import { ConnectorAdapter } from '../../../../connector_adapters/types'; import { RuleDomain } from '../../types'; import { RuleSystemAction } from '../../../../types'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; jest.mock('../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), @@ -91,6 +92,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), connectorAdapterRegistry, isSystemAction: jest.fn(), uiSettings: uiSettingsServiceMock.createStartContract(), diff --git a/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts index a0b44abde3c54..f7f8e069e9822 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/get_schedule_frequency/get_schedule_frequency.test.ts @@ -21,6 +21,7 @@ import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { AlertingAuthorization } from '../../../../authorization/alerting_authorization'; import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; const taskManager = taskManagerMock.createStart(); @@ -58,6 +59,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve.test.ts index b611f5f2d3ef5..d1706a890c8e8 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/resolve/resolve.test.ts @@ -23,6 +23,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; import { getBeforeSetup } from '../../../../rules_client/tests/lib'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; describe('resolve', () => { const taskManager = taskManagerMock.createStart(); @@ -33,6 +34,7 @@ describe('resolve', () => { const actionsAuthorization = actionsAuthorizationMock.create(); const auditLogger = auditLoggerMock.create(); const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const backfillClient = backfillClientMock.create(); const kibanaVersion = 'v8.2.0'; const createAPIKeyMock = jest.fn(); @@ -60,6 +62,7 @@ describe('resolve', () => { minimumScheduleInterval: { value: '1m', enforce: false }, isAuthenticationTypeAPIKey: isAuthenticationTypeApiKeyMock, getAuthenticationAPIKey: getAuthenticationApiKeyMock, + backfillClient, connectorAdapterRegistry: new ConnectorAdapterRegistry(), isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), diff --git a/x-pack/plugins/alerting/server/application/rule/methods/tags/get_rule_tags.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/tags/get_rule_tags.test.ts index e6c99f779909d..de356c3a2e2b9 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/tags/get_rule_tags.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/tags/get_rule_tags.test.ts @@ -25,6 +25,7 @@ import { RecoveredActionGroup } from '../../../../../common'; import { RegistryRuleType } from '../../../../rule_type_registry'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -60,6 +61,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/application/rule/methods/update/update_rule.test.ts b/x-pack/plugins/alerting/server/application/rule/methods/update/update_rule.test.ts index eb36b28c40875..d9bc9c8816845 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/update/update_rule.test.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/update/update_rule.test.ts @@ -32,6 +32,7 @@ import { migrateLegacyActions } from '../../../../rules_client/lib'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; import { ConnectorAdapterRegistry } from '../../../../connector_adapters/connector_adapter_registry'; import { RuleDomain } from '../../types'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; jest.mock('../../../../rules_client/lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -98,6 +99,7 @@ const rulesClientParams: jest.Mocked = { isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index aefbdb85f7e06..2102ff245b9f8 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -35,6 +35,8 @@ export enum ReadOperations { Find = 'find', GetAuthorizedAlertsIndices = 'getAuthorizedAlertsIndices', GetRuleExecutionKPI = 'getRuleExecutionKPI', + GetBackfill = 'getBackfill', + FindBackfill = 'findBackfill', } export enum WriteOperations { @@ -55,6 +57,8 @@ export enum WriteOperations { BulkDisable = 'bulkDisable', Unsnooze = 'unsnooze', RunSoon = 'runSoon', + ScheduleBackfill = 'scheduleBackfill', + DeleteBackfill = 'deleteBackfill', } export interface EnsureAuthorizedOpts { diff --git a/x-pack/plugins/alerting/server/backfill_client/backfill_client.mock.ts b/x-pack/plugins/alerting/server/backfill_client/backfill_client.mock.ts new file mode 100644 index 0000000000000..f42cbb06f142a --- /dev/null +++ b/x-pack/plugins/alerting/server/backfill_client/backfill_client.mock.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ +const createBackfillClientMock = () => { + return jest.fn().mockImplementation(() => { + return { + bulkQueue: jest.fn(), + }; + }); +}; + +export const backfillClientMock = { + create: createBackfillClientMock(), +}; diff --git a/x-pack/plugins/alerting/server/backfill_client/backfill_client.test.ts b/x-pack/plugins/alerting/server/backfill_client/backfill_client.test.ts new file mode 100644 index 0000000000000..096d7ddb2e445 --- /dev/null +++ b/x-pack/plugins/alerting/server/backfill_client/backfill_client.test.ts @@ -0,0 +1,858 @@ +/* + * 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 { adHocRunStatus } from '../../common/constants'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { SavedObject, SavedObjectsBulkResponse } from '@kbn/core/server'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { ScheduleBackfillParam } from '../application/backfill/methods/schedule/types'; +import { RuleDomain } from '../application/rule/types'; +import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { BackfillClient } from './backfill_client'; +import { AdHocRunSO } from '../data/ad_hoc_run/types'; +import { transformAdHocRunToBackfillResult } from '../application/backfill/transforms'; +import { RecoveredActionGroup } from '@kbn/alerting-types'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { TaskRunnerFactory } from '../task_runner'; +import { TaskPriority } from '@kbn/task-manager-plugin/server'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; + +const logger = loggingSystemMock.create().get(); +const taskManagerSetup = taskManagerMock.createSetup(); +const taskManagerStart = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const auditLogger = auditLoggerMock.create(); + +function getMockData(overwrites: Record = {}): ScheduleBackfillParam { + return { + ruleId: '1', + start: '2023-11-16T08:00:00.000Z', + ...overwrites, + }; +} + +const mockRuleType: jest.Mocked = { + id: 'myType', + name: 'Test', + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'custom', name: 'Not the Default' }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + category: 'test', + producer: 'alerts', + validate: { + params: { validate: (params) => params }, + }, + validLegacyConsumers: [], + autoRecoverAlerts: false, +}; + +const MOCK_API_KEY = Buffer.from('123:abc').toString('base64'); +function getMockRule(overwrites: Record = {}): RuleDomain { + return { + id: '1', + actions: [], + alertTypeId: 'myType', + apiKey: MOCK_API_KEY, + apiKeyCreatedByUser: false, + apiKeyOwner: 'user', + consumer: 'myApp', + createdAt: new Date('2019-02-12T21:01:22.479Z'), + createdBy: 'user', + enabled: true, + executionStatus: { + lastExecutionDate: new Date('2019-02-12T21:01:22.479Z'), + status: 'pending', + }, + muteAll: false, + mutedInstanceIds: [], + name: 'my rule name', + notifyWhen: null, + // @ts-expect-error + params: {}, + revision: 0, + schedule: { interval: '12h' }, + scheduledTaskId: 'task-123', + snoozeSchedule: [], + tags: ['foo'], + throttle: null, + updatedAt: new Date('2019-02-12T21:01:22.479Z'), + updatedBy: 'user', + ...overwrites, + }; +} + +function getMockAdHocRunAttributes({ + ruleId, + overwrites, + omitApiKey = false, +}: { + ruleId?: string; + overwrites?: Record; + omitApiKey?: boolean; +} = {}): AdHocRunSO { + return { + ...(omitApiKey ? {} : { apiKeyId: '123', apiKeyToUse: 'MTIzOmFiYw==' }), + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + ...(ruleId ? { id: ruleId } : {}), + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + // @ts-expect-error + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { + interval: '12h', + }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + }, + spaceId: 'default', + start: '2023-10-19T15:07:40.011Z', + status: adHocRunStatus.PENDING, + schedule: [ + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T03:07:40.011Z', + }, + { + interval: '12h', + status: adHocRunStatus.PENDING, + runAt: '2023-10-20T15:07:40.011Z', + }, + ], + ...overwrites, + }; +} + +function getBulkCreateParam( + id: string, + ruleId: string, + attributes: AdHocRunSO +): SavedObject { + return { + type: 'ad_hoc_rule_run_params', + id, + namespaces: ['default'], + attributes, + references: [ + { + id: ruleId, + name: 'rule', + type: 'alert', + }, + ], + managed: false, + coreMigrationVersion: '8.8.0', + updated_at: '2024-02-07T16:05:39.296Z', + created_at: '2024-02-07T16:05:39.296Z', + version: 'WzcsMV0=', + }; +} + +describe('BackfillClient', () => { + let backfillClient: BackfillClient; + + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2024-01-30T00:00:00.000Z')); + }); + + beforeEach(() => { + jest.resetAllMocks(); + ruleTypeRegistry.get.mockReturnValue(mockRuleType); + backfillClient = new BackfillClient({ + logger, + taskManagerSetup, + taskManagerStartPromise: Promise.resolve(taskManagerStart), + taskRunnerFactory: new TaskRunnerFactory(), + }); + }); + + afterAll(() => jest.useRealTimers()); + + describe('constructor', () => { + test('should register backfill task type', async () => { + expect(taskManagerSetup.registerTaskDefinitions).toHaveBeenCalledWith({ + 'ad_hoc_run-backfill': { + title: 'Alerting Backfill Rule Run', + priority: TaskPriority.Low, + createTaskRunner: expect.any(Function), + }, + }); + }); + }); + + describe('bulkQueue()', () => { + test('should successfully create backfill saved objects and queue backfill tasks', async () => { + const mockData = [ + getMockData(), + getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' }), + ]; + const rule1 = getMockRule(); + const rule2 = getMockRule({ id: '2' }); + const mockRules = [rule1, rule2]; + ruleTypeRegistry.get.mockReturnValue({ ...mockRuleType, ruleTaskTimeout: '1d' }); + + const mockAttributes1 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T20:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + const mockAttributes2 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-17T08:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + { + runAt: '2023-11-17T08:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + const bulkCreateResult = { + saved_objects: [ + getBulkCreateParam('abc', '1', mockAttributes1), + getBulkCreateParam('def', '2', mockAttributes2), + ], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + const result = await backfillClient.bulkQueue({ + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith([ + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: mockAttributes1, + references: [{ id: rule1.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + }, + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: mockAttributes2, + references: [{ id: rule2.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + }, + ]); + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'abc', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'def', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=def]', + }); + expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ + { + id: 'abc', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'abc', spaceId: 'default' }, + }, + { + id: 'def', + taskType: 'ad_hoc_run-backfill', + state: {}, + timeoutOverride: '1d', + params: { adHocRunParamsId: 'def', spaceId: 'default' }, + }, + ]); + expect(result).toEqual(bulkCreateResult.saved_objects.map(transformAdHocRunToBackfillResult)); + }); + + test('should successfully create multiple backfill saved objects for a single rule', async () => { + const mockData = [getMockData(), getMockData({ end: '2023-11-17T08:00:00.000Z' })]; + const rule1 = getMockRule(); + const mockRules = [rule1]; + + const mockAttributes1 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T20:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + const mockAttributes2 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-17T08:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + { + runAt: '2023-11-17T08:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + const bulkCreateResult = { + saved_objects: [ + getBulkCreateParam('abc', '1', mockAttributes1), + getBulkCreateParam('def', '1', mockAttributes2), + ], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + const result = await backfillClient.bulkQueue({ + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith([ + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: mockAttributes1, + references: [{ id: rule1.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + }, + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: mockAttributes2, + references: [{ id: rule1.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + }, + ]); + expect(auditLogger.log).toHaveBeenCalledTimes(2); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'abc', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'def', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=def]', + }); + expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ + { + id: 'abc', + taskType: 'ad_hoc_run-backfill', + state: {}, + params: { adHocRunParamsId: 'abc', spaceId: 'default' }, + }, + { + id: 'def', + taskType: 'ad_hoc_run-backfill', + state: {}, + params: { adHocRunParamsId: 'def', spaceId: 'default' }, + }, + ]); + expect(result).toEqual(bulkCreateResult.saved_objects.map(transformAdHocRunToBackfillResult)); + }); + + test('should log warning if no rule found for backfill job', async () => { + const mockData = [ + getMockData(), + getMockData({ ruleId: '2', end: '2023-11-17T08:00:00.000Z' }), + ]; + const rule1 = getMockRule(); + const mockRules = [rule1]; + + const mockAttributes1 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T20:00:00.000Z', + schedule: [ + { + runAt: '2023-11-16T20:00:00.000Z', + interval: '12h', + status: adHocRunStatus.PENDING, + }, + ], + }, + }); + + const bulkCreateResult = { + saved_objects: [getBulkCreateParam('abc', '1', mockAttributes1)], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); + + const result = await backfillClient.bulkQueue({ + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith([ + { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: mockAttributes1, + references: [{ id: rule1.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], + }, + ]); + expect(auditLogger.log).toHaveBeenCalledTimes(1); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'abc', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', + }); + expect(logger.warn).toHaveBeenCalledWith( + `No rule found for ruleId 2 - not scheduling backfill for {\"ruleId\":\"2\",\"start\":\"2023-11-16T08:00:00.000Z\",\"end\":\"2023-11-17T08:00:00.000Z\"}` + ); + expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ + { + id: 'abc', + taskType: 'ad_hoc_run-backfill', + state: {}, + params: { adHocRunParamsId: 'abc', spaceId: 'default' }, + }, + ]); + expect(result).toEqual([ + ...bulkCreateResult.saved_objects.map(transformAdHocRunToBackfillResult), + { + error: { + error: 'Not Found', + message: 'Saved object [alert/2] not found', + }, + }, + ]); + }); + + test('should return backfill result or error message for each backfill param', async () => { + ruleTypeRegistry.get.mockReturnValueOnce({ + id: 'myType', + name: 'Test', + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'custom', name: 'Not the Default' }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + async executor() { + return { state: {} }; + }, + category: 'test', + producer: 'alerts', + validate: { + params: { validate: (params) => params }, + }, + validLegacyConsumers: [], + autoRecoverAlerts: true, + }); + const mockData = [ + getMockData(), // this should return error due to unsupported rule type + getMockData(), // this should succeed + getMockData({ ruleId: '2', end: '2023-11-16T10:00:00.000Z' }), // this should return rule not found error + getMockData({ ruleId: '3', end: '2023-11-16T12:00:00.000Z' }), // this should succeed + getMockData({ end: '2023-11-16T09:00:00.000Z' }), // this should succeed + getMockData({ ruleId: '4' }), // this should return error from saved objects client bulk create + getMockData({ ruleId: '5' }), // this should succeed + getMockData({ ruleId: '6' }), // this should return error due to disabled rule + getMockData({ ruleId: '7' }), // this should return error due to null api key + ]; + const rule1 = getMockRule(); + const rule3 = getMockRule({ id: '3' }); + const rule4 = getMockRule({ id: '4' }); + const rule5 = getMockRule({ id: '5' }); + const rule6 = getMockRule({ id: '6', enabled: false }); + const rule7 = getMockRule({ id: '7', apiKey: null }); + const mockRules = [rule1, rule3, rule4, rule5, rule6, rule7]; + + const mockAttributes = getMockAdHocRunAttributes(); + + const bulkCreateResult = { + saved_objects: [ + getBulkCreateParam('abc', '1', mockAttributes), + getBulkCreateParam('def', '3', mockAttributes), + getBulkCreateParam('ghi', '1', mockAttributes), + { + type: 'ad_hoc_rule_run_params', + error: { + error: 'my error', + message: 'Unable to create', + }, + }, + getBulkCreateParam('jkl', '5', mockAttributes), + ], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce( + bulkCreateResult as SavedObjectsBulkResponse + ); + const result = await backfillClient.bulkQueue({ + auditLogger, + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + expect(auditLogger.log).toHaveBeenCalledTimes(5); + expect(auditLogger.log).toHaveBeenNthCalledWith(1, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'abc', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=abc]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(2, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'def', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=def]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(3, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'ghi', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=ghi]', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(4, { + error: { code: 'Error', message: 'Unable to create' }, + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'failure', + type: ['creation'], + }, + kibana: {}, + message: 'Failed attempt to create ad hoc run for an ad hoc run', + }); + expect(auditLogger.log).toHaveBeenNthCalledWith(5, { + event: { + action: 'ad_hoc_run_create', + category: ['database'], + outcome: 'success', + type: ['creation'], + }, + kibana: { saved_object: { id: 'jkl', type: 'ad_hoc_run_params' } }, + message: 'User has created ad hoc run for ad_hoc_run_params [id=jkl]', + }); + + expect(taskManagerStart.bulkSchedule).toHaveBeenCalledWith([ + { + id: 'abc', + taskType: 'ad_hoc_run-backfill', + state: {}, + params: { adHocRunParamsId: 'abc', spaceId: 'default' }, + }, + { + id: 'def', + taskType: 'ad_hoc_run-backfill', + state: {}, + params: { adHocRunParamsId: 'def', spaceId: 'default' }, + }, + { + id: 'ghi', + taskType: 'ad_hoc_run-backfill', + state: {}, + params: { adHocRunParamsId: 'ghi', spaceId: 'default' }, + }, + { + id: 'jkl', + taskType: 'ad_hoc_run-backfill', + state: {}, + params: { adHocRunParamsId: 'jkl', spaceId: 'default' }, + }, + ]); + + expect(result).toEqual([ + { + error: { + error: 'Bad Request', + message: 'Rule type "myType" for rule 1 is not supported', + }, + }, + { + id: 'abc', + ...getMockAdHocRunAttributes({ ruleId: '1', omitApiKey: true }), + }, + { + error: { + error: 'Not Found', + message: 'Saved object [alert/2] not found', + }, + }, + { + id: 'def', + ...getMockAdHocRunAttributes({ ruleId: '3', omitApiKey: true }), + }, + { + id: 'ghi', + ...getMockAdHocRunAttributes({ ruleId: '1', omitApiKey: true }), + }, + { + error: { + error: 'my error', + message: 'Unable to create', + }, + }, + { + id: 'jkl', + ...getMockAdHocRunAttributes({ ruleId: '5', omitApiKey: true }), + }, + { + error: { + error: 'Bad Request', + message: 'Rule 6 is disabled', + }, + }, + { + error: { + error: 'Bad Request', + message: 'Rule 7 has no API key', + }, + }, + ]); + }); + + test('should skip calling bulkCreate if no rules found for any backfill job', async () => { + const mockData = [ + getMockData(), // this should succeed + getMockData({ ruleId: '2', end: '2023-11-16T10:00:00.000Z' }), // this should return rule not found error + getMockData({ ruleId: '3', end: '2023-11-16T12:00:00.000Z' }), // this should succeed + getMockData({ end: '2023-11-16T09:00:00.000Z' }), // this should succeed + getMockData({ ruleId: '4' }), // this should return error from saved objects client bulk create + getMockData({ ruleId: '5' }), // this should succeed + ]; + + const result = await backfillClient.bulkQueue({ + auditLogger, + params: mockData, + rules: [], + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + expect(auditLogger.log).not.toHaveBeenCalled(); + expect(taskManagerStart.bulkSchedule).not.toHaveBeenCalled(); + expect(result).toEqual([ + { + error: { + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }, + }, + { + error: { + error: 'Not Found', + message: 'Saved object [alert/2] not found', + }, + }, + { + error: { + error: 'Not Found', + message: 'Saved object [alert/3] not found', + }, + }, + { + error: { + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }, + }, + { + error: { + error: 'Not Found', + message: 'Saved object [alert/4] not found', + }, + }, + { + error: { + error: 'Not Found', + message: 'Saved object [alert/5] not found', + }, + }, + ]); + }); + + test('should skip calling bulkSchedule if no SOs were successfully created', async () => { + ruleTypeRegistry.get.mockReturnValueOnce({ + id: 'myType', + name: 'Test', + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'custom', name: 'Not the Default' }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + async executor() { + return { state: {} }; + }, + category: 'test', + producer: 'alerts', + validate: { + params: { validate: (params) => params }, + }, + validLegacyConsumers: [], + autoRecoverAlerts: true, + }); + const mockData = [ + getMockData(), // this should return error due to unsupported rule type + getMockData({ ruleId: '2', end: '2023-11-16T10:00:00.000Z' }), // this should return rule not found error + getMockData({ ruleId: '4' }), // this should return error from saved objects client bulk create + getMockData({ ruleId: '6' }), // this should return error due to disabled rule + getMockData({ ruleId: '7' }), // this should return error due to null api key + ]; + const rule1 = getMockRule(); + const rule4 = getMockRule({ id: '4' }); + const rule6 = getMockRule({ id: '6', enabled: false }); + const rule7 = getMockRule({ id: '7', apiKey: null }); + const mockRules = [rule1, rule4, rule6, rule7]; + + const bulkCreateResult = { + saved_objects: [ + { + type: 'ad_hoc_rule_run_params', + error: { + error: 'my error', + message: 'Unable to create', + }, + }, + ], + }; + + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce( + bulkCreateResult as SavedObjectsBulkResponse + ); + const result = await backfillClient.bulkQueue({ + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + expect(taskManagerStart.bulkSchedule).not.toHaveBeenCalled(); + + expect(result).toEqual([ + { + error: { + error: 'Bad Request', + message: 'Rule type "myType" for rule 1 is not supported', + }, + }, + { + error: { + error: 'Not Found', + message: 'Saved object [alert/2] not found', + }, + }, + { + error: { + error: 'my error', + message: 'Unable to create', + }, + }, + { + error: { + error: 'Bad Request', + message: 'Rule 6 is disabled', + }, + }, + { + error: { + error: 'Bad Request', + message: 'Rule 7 has no API key', + }, + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/backfill_client/backfill_client.ts b/x-pack/plugins/alerting/server/backfill_client/backfill_client.ts new file mode 100644 index 0000000000000..7b4c7aeea225d --- /dev/null +++ b/x-pack/plugins/alerting/server/backfill_client/backfill_client.ts @@ -0,0 +1,279 @@ +/* + * 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 { + Logger, + SavedObject, + SavedObjectReference, + SavedObjectsBulkCreateObject, + SavedObjectsClientContract, + SavedObjectsErrorHelpers, +} from '@kbn/core/server'; +import { AuditLogger } from '@kbn/security-plugin/server'; +import { + RunContext, + TaskInstance, + TaskManagerSetupContract, + TaskManagerStartContract, + TaskPriority, +} from '@kbn/task-manager-plugin/server'; +import { isNumber } from 'lodash'; +import { + ScheduleBackfillError, + ScheduleBackfillParam, + ScheduleBackfillParams, + ScheduleBackfillResult, + ScheduleBackfillResults, +} from '../application/backfill/methods/schedule/types'; +import { Backfill } from '../application/backfill/result/types'; +import { + transformBackfillParamToAdHocRun, + transformAdHocRunToBackfillResult, +} from '../application/backfill/transforms'; +import { RuleDomain } from '../application/rule/types'; +import { AdHocRunSO } from '../data/ad_hoc_run/types'; +import { AdHocRunAuditAction, adHocRunAuditEvent } from '../rules_client/common/audit_events'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { TaskRunnerFactory } from '../task_runner'; +import { RuleTypeRegistry } from '../types'; +import { createBackfillError } from './lib'; + +export const BACKFILL_TASK_TYPE = 'ad_hoc_run-backfill'; + +interface ConstructorOpts { + logger: Logger; + taskManagerSetup: TaskManagerSetupContract; + taskManagerStartPromise: Promise; + taskRunnerFactory: TaskRunnerFactory; +} + +interface BulkQueueOpts { + auditLogger?: AuditLogger; + params: ScheduleBackfillParams; + rules: RuleDomain[]; + ruleTypeRegistry: RuleTypeRegistry; + spaceId: string; + unsecuredSavedObjectsClient: SavedObjectsClientContract; +} + +export class BackfillClient { + private logger: Logger; + private readonly taskManagerStartPromise: Promise; + + constructor(opts: ConstructorOpts) { + this.logger = opts.logger; + this.taskManagerStartPromise = opts.taskManagerStartPromise; + + // Registers the task that handles the backfill using the ad hoc task runner + opts.taskManagerSetup.registerTaskDefinitions({ + [BACKFILL_TASK_TYPE]: { + title: 'Alerting Backfill Rule Run', + priority: TaskPriority.Low, + createTaskRunner: (context: RunContext) => opts.taskRunnerFactory.createAdHoc(context), + }, + }); + } + + public async bulkQueue({ + auditLogger, + params, + rules, + ruleTypeRegistry, + spaceId, + unsecuredSavedObjectsClient, + }: BulkQueueOpts): Promise { + const adHocSOsToCreate: Array> = []; + + /** + * soToCreateIndexOrErrorMap contains a map of the original request index to the + * AdHocRunSO to create index in the adHocSOsToCreate array or any errors + * encountered while processing the request + * + * For example, if the original request has 5 entries, 2 of which result in errors, + * the map will look like: + * + * params: [request1, request2, request3, request4, request5] + * adHocSOsToCreate: [AdHocRunSO1, AdHocRunSO3, AdHocRunSO4] + * soToCreateIndexOrErrorMap: { + * 0: 0, + * 1: error1, + * 2: 1, + * 3: 2, + * 4: error2 + * } + * + * This allows us to return a response in the same order the requests were received + */ + + const soToCreateIndexOrErrorMap: Map = new Map(); + + params.forEach((param: ScheduleBackfillParam, ndx: number) => { + // For this schedule request, look up the rule or return error + const { rule, error } = getRuleOrError(param.ruleId, rules, ruleTypeRegistry); + if (rule) { + // keep track of index of this request in the adHocSOsToCreate array + soToCreateIndexOrErrorMap.set(ndx, adHocSOsToCreate.length); + const reference: SavedObjectReference = { + id: rule.id, + name: `rule`, + type: RULE_SAVED_OBJECT_TYPE, + }; + adHocSOsToCreate.push({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: transformBackfillParamToAdHocRun(param, rule, spaceId), + references: [reference], + }); + } else if (error) { + // keep track of the error encountered for this request by index so + // we can return it in order + soToCreateIndexOrErrorMap.set(ndx, error); + this.logger.warn( + `No rule found for ruleId ${param.ruleId} - not scheduling backfill for ${JSON.stringify( + param + )}` + ); + } + }); + + // Every request encountered an error, so short-circuit the logic here + if (!adHocSOsToCreate.length) { + return params.map( + (_, ndx: number) => soToCreateIndexOrErrorMap.get(ndx) as ScheduleBackfillError + ); + } + + // Bulk create the saved object + const bulkCreateResponse = await unsecuredSavedObjectsClient.bulkCreate( + adHocSOsToCreate + ); + + const transformedResponse: ScheduleBackfillResults = bulkCreateResponse.saved_objects.map( + (so: SavedObject) => { + if (so.error) { + auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.CREATE, + error: new Error(so.error.message), + }) + ); + } else { + auditLogger?.log( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.CREATE, + savedObject: { type: AD_HOC_RUN_SAVED_OBJECT_TYPE, id: so.id }, + }) + ); + } + return transformAdHocRunToBackfillResult(so); + } + ); + + /** + * Use soToCreateIndexOrErrorMap to build the result array that returns + * the bulkQueue result in the same order of the request + * + * For example, if we have 3 entries in the bulkCreateResponse + * + * bulkCreateResult: [AdHocRunSO1, AdHocRunSO3, AdHocRunSO4] + * soToCreateIndexOrErrorMap: { + * 0: 0, + * 1: error1, + * 2: 1, + * 3: 2, + * 4: error2 + * } + * + * The following result would be returned + * result: [AdHocRunSO1, error1, AdHocRunSO3, AdHocRunSO4, error2] + */ + const createSOResult = Array.from(soToCreateIndexOrErrorMap.keys()).map((ndx: number) => { + const indexOrError = soToCreateIndexOrErrorMap.get(ndx); + + if (isNumber(indexOrError)) { + // This number is the index of the response from the savedObjects bulkCreate function + return transformedResponse[indexOrError]; + } else { + // Return the error we encountered + return indexOrError as ScheduleBackfillError; + } + }); + + // Build array of tasks to schedule + const adHocTasksToSchedule: TaskInstance[] = []; + createSOResult.forEach((result: ScheduleBackfillResult) => { + if (!(result as ScheduleBackfillError).error) { + const createdSO = result as Backfill; + + const ruleTypeTimeout = ruleTypeRegistry.get(createdSO.rule.alertTypeId).ruleTaskTimeout; + adHocTasksToSchedule.push({ + id: createdSO.id, + taskType: BACKFILL_TASK_TYPE, + ...(ruleTypeTimeout ? { timeoutOverride: ruleTypeTimeout } : {}), + state: {}, + params: { + adHocRunParamsId: createdSO.id, + spaceId, + }, + }); + } + }); + + if (adHocTasksToSchedule.length > 0) { + const taskManager = await this.taskManagerStartPromise; + await taskManager.bulkSchedule(adHocTasksToSchedule); + } + + return createSOResult; + } +} + +function getRuleOrError( + ruleId: string, + rules: RuleDomain[], + ruleTypeRegistry: RuleTypeRegistry +): { rule?: RuleDomain; error?: ScheduleBackfillError } { + const rule = rules.find((r: RuleDomain) => r.id === ruleId); + + // if rule not found, return not found error + if (!rule) { + const notFoundError = SavedObjectsErrorHelpers.createGenericNotFoundError( + RULE_SAVED_OBJECT_TYPE, + ruleId + ); + return { + error: createBackfillError( + notFoundError.output.payload.error, + notFoundError.output.payload.message + ), + }; + } + + // if rule exists, check that it is enabled + if (!rule.enabled) { + return { error: createBackfillError('Bad Request', `Rule ${ruleId} is disabled`) }; + } + + // check that the rule type is supported + const isLifecycleRule = ruleTypeRegistry.get(rule.alertTypeId).autoRecoverAlerts ?? true; + if (isLifecycleRule) { + return { + error: createBackfillError( + 'Bad Request', + `Rule type "${rule.alertTypeId}" for rule ${ruleId} is not supported` + ), + }; + } + + // check that the API key is not null + if (!rule.apiKey) { + return { + error: createBackfillError('Bad Request', `Rule ${ruleId} has no API key`), + }; + } + + return { rule }; +} diff --git a/x-pack/plugins/alerting/server/backfill_client/lib/calculate_schedule.test.ts b/x-pack/plugins/alerting/server/backfill_client/lib/calculate_schedule.test.ts new file mode 100644 index 0000000000000..ceeb57371709a --- /dev/null +++ b/x-pack/plugins/alerting/server/backfill_client/lib/calculate_schedule.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { adHocRunStatus } from '../../../common/constants'; +import { calculateSchedule } from './calculate_schedule'; + +describe('calculateSchedule', () => { + test('should calculate schedule using start and end time', () => { + expect(calculateSchedule('2023-11-16T08:00:00.000Z', '1h', '2023-11-16T13:00:00.000Z')).toEqual( + [ + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T09:00:00.000Z', + }, + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T10:00:00.000Z', + }, + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T11:00:00.000Z', + }, + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T12:00:00.000Z', + }, + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T13:00:00.000Z', + }, + ] + ); + + expect( + calculateSchedule('2023-11-16T08:42:45.751Z', '24m', '2023-11-16T10:54:23.000Z') + ).toEqual([ + { + interval: '24m', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T09:06:45.751Z', + }, + { + interval: '24m', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T09:30:45.751Z', + }, + { + interval: '24m', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T09:54:45.751Z', + }, + { + interval: '24m', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T10:18:45.751Z', + }, + { + interval: '24m', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T10:42:45.751Z', + }, + { + interval: '24m', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T11:06:45.751Z', + }, + ]); + }); + + test('should calculate schedule with no end time', () => { + expect(calculateSchedule('2023-11-16T08:00:00.000Z', '1h')).toEqual([ + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T09:00:00.000Z', + }, + ]); + }); + + test('should calculate schedule when start and end are not multiple of interval', () => { + expect(calculateSchedule('2023-11-16T08:00:00.000Z', '1h', '2023-11-16T12:38:23.252Z')).toEqual( + [ + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T09:00:00.000Z', + }, + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T10:00:00.000Z', + }, + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T11:00:00.000Z', + }, + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T12:00:00.000Z', + }, + { + interval: '1h', + status: adHocRunStatus.PENDING, + runAt: '2023-11-16T13:00:00.000Z', + }, + ] + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/backfill_client/lib/calculate_schedule.ts b/x-pack/plugins/alerting/server/backfill_client/lib/calculate_schedule.ts new file mode 100644 index 0000000000000..f86738c296218 --- /dev/null +++ b/x-pack/plugins/alerting/server/backfill_client/lib/calculate_schedule.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { adHocRunStatus } from '../../../common/constants'; +import { parseDuration } from '../../../common'; +import { AdHocRunSchedule } from '../../data/ad_hoc_run/types'; + +export function calculateSchedule( + start: string, + interval: string, + end?: string +): AdHocRunSchedule[] { + const schedule: AdHocRunSchedule[] = []; + const intervalInMs = parseDuration(interval); + + let currentStart: Date = new Date(start); + let currentEnd; + do { + currentEnd = new Date(currentStart.valueOf() + intervalInMs); + schedule.push({ status: adHocRunStatus.PENDING, runAt: currentEnd.toISOString(), interval }); + + currentStart = currentEnd; + } while (end && currentEnd && currentEnd.valueOf() < new Date(end).valueOf()); + + return schedule; +} diff --git a/x-pack/plugins/alerting/server/backfill_client/lib/create_backfill_error.ts b/x-pack/plugins/alerting/server/backfill_client/lib/create_backfill_error.ts new file mode 100644 index 0000000000000..050c19f29b1f4 --- /dev/null +++ b/x-pack/plugins/alerting/server/backfill_client/lib/create_backfill_error.ts @@ -0,0 +1,12 @@ +/* + * 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 { ScheduleBackfillError } from '../../application/backfill/methods/schedule/types'; + +export function createBackfillError(error: string, message: string): ScheduleBackfillError { + return { error: { error, message } }; +} diff --git a/x-pack/plugins/alerting/server/backfill_client/lib/index.ts b/x-pack/plugins/alerting/server/backfill_client/lib/index.ts new file mode 100644 index 0000000000000..b1425d7ace279 --- /dev/null +++ b/x-pack/plugins/alerting/server/backfill_client/lib/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { calculateSchedule } from './calculate_schedule'; +export { createBackfillError } from './create_backfill_error'; diff --git a/x-pack/plugins/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts b/x-pack/plugins/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts new file mode 100644 index 0000000000000..be03f45749c5d --- /dev/null +++ b/x-pack/plugins/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts @@ -0,0 +1,75 @@ +/* + * 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 { RuleDomain } from '../../../application/rule/types'; +import { AdHocRunStatus } from '../../../../common/constants'; + +export interface AdHocRunSchedule extends Record { + interval: string; + status: AdHocRunStatus; + runAt: string; +} + +// This is the rule information stored in the AD_HOC_RUN_SAVED_OBJECT_TYPE saved object +// - we do not include the rule ID because that is stored in the SO references array +// - we copy over the API key from the rule at the time the backfill was scheduled to use in +// the ad hoc task runner +// - all the other rule fields are copied because we use it as part of rule execution +// - we copy over this information in order to run the rule as it was configured when +// the backfill job was scheduled. if there are updates to the rule configuration +// after the backfill is scheduled, they will not be reflected during the backfill run. +type AdHocRunSORule = Pick< + RuleDomain, + | 'name' + | 'tags' + | 'alertTypeId' + | 'params' + | 'apiKeyOwner' + | 'apiKeyCreatedByUser' + | 'consumer' + | 'enabled' + | 'schedule' + | 'createdBy' + | 'updatedBy' + | 'revision' +> & { + createdAt: string; + updatedAt: string; +}; + +// This is the rule information after loaded from persistence with the +// rule ID injected from the SO references array +type AdHocRunRule = AdHocRunSORule & Pick; + +export interface AdHocRunSO extends Record { + apiKeyId: string; + apiKeyToUse: string; + createdAt: string; + duration: string; + enabled: boolean; + end?: string; + rule: AdHocRunSORule; + spaceId: string; + start: string; + status: AdHocRunStatus; + schedule: AdHocRunSchedule[]; +} + +export interface AdHocRun { + apiKeyId: string; + apiKeyToUse: string; + createdAt: string; + duration: string; + enabled: boolean; + end?: string; + id: string; + rule: AdHocRunRule; + spaceId: string; + start: string; + status: AdHocRunStatus; + schedule: AdHocRunSchedule[]; +} diff --git a/x-pack/plugins/alerting/server/data/ad_hoc_run/types/index.ts b/x-pack/plugins/alerting/server/data/ad_hoc_run/types/index.ts new file mode 100644 index 0000000000000..85b304ba7a02b --- /dev/null +++ b/x-pack/plugins/alerting/server/data/ad_hoc_run/types/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { AdHocRunSO, AdHocRunSchedule, AdHocRun } from './ad_hoc_run'; diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index c6746d33df67c..84d99c15d805f 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -35,7 +35,7 @@ export type { DataStreamAdapter, } from './types'; export { DEFAULT_AAD_CONFIG } from './types'; -export { RULE_SAVED_OBJECT_TYPE } from './saved_objects'; +export { RULE_SAVED_OBJECT_TYPE, API_KEY_PENDING_INVALIDATION_TYPE } from './saved_objects'; export { RuleNotifyWhen } from '../common'; export { DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT } from './config'; export type { PluginSetupContract, PluginStartContract } from './plugin'; diff --git a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap index 790e3f71e071c..6e741c8627070 100644 --- a/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap +++ b/x-pack/plugins/alerting/server/integration_tests/__snapshots__/alert_as_data_fields.test.ts.snap @@ -1034,6 +1034,11 @@ Object { "required": false, "type": "object", }, + "kibana.alert.rule.execution.timestamp": Object { + "array": false, + "required": false, + "type": "date", + }, "kibana.alert.rule.execution.uuid": Object { "array": false, "required": false, @@ -2086,6 +2091,11 @@ Object { "required": false, "type": "object", }, + "kibana.alert.rule.execution.timestamp": Object { + "array": false, + "required": false, + "type": "date", + }, "kibana.alert.rule.execution.uuid": Object { "array": false, "required": false, @@ -3138,6 +3148,11 @@ Object { "required": false, "type": "object", }, + "kibana.alert.rule.execution.timestamp": Object { + "array": false, + "required": false, + "type": "date", + }, "kibana.alert.rule.execution.uuid": Object { "array": false, "required": false, @@ -4190,6 +4205,11 @@ Object { "required": false, "type": "object", }, + "kibana.alert.rule.execution.timestamp": Object { + "array": false, + "required": false, + "type": "date", + }, "kibana.alert.rule.execution.uuid": Object { "array": false, "required": false, @@ -5242,6 +5262,11 @@ Object { "required": false, "type": "object", }, + "kibana.alert.rule.execution.timestamp": Object { + "array": false, + "required": false, + "type": "date", + }, "kibana.alert.rule.execution.uuid": Object { "array": false, "required": false, @@ -6300,6 +6325,11 @@ Object { "required": false, "type": "object", }, + "kibana.alert.rule.execution.timestamp": Object { + "array": false, + "required": false, + "type": "date", + }, "kibana.alert.rule.execution.uuid": Object { "array": false, "required": false, @@ -7352,6 +7382,11 @@ Object { "required": false, "type": "object", }, + "kibana.alert.rule.execution.timestamp": Object { + "array": false, + "required": false, + "type": "date", + }, "kibana.alert.rule.execution.uuid": Object { "array": false, "required": false, @@ -8404,6 +8439,11 @@ Object { "required": false, "type": "object", }, + "kibana.alert.rule.execution.timestamp": Object { + "array": false, + "required": false, + "type": "date", + }, "kibana.alert.rule.execution.uuid": Object { "array": false, "required": false, diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts index 34545292bf5f8..270661363a5be 100644 --- a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts +++ b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts @@ -6,6 +6,7 @@ */ import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { API_KEY_PENDING_INVALIDATION_TYPE } from '..'; import { bulkMarkApiKeysForInvalidation } from './bulk_mark_api_keys_for_invalidation'; describe('bulkMarkApiKeysForInvalidation', () => { @@ -26,10 +27,10 @@ describe('bulkMarkApiKeysForInvalidation', () => { expect(bulkCreateCallMock).toHaveLength(1); expect(savedObjects).toHaveLength(2); - expect(savedObjects[0]).toHaveProperty('type', 'api_key_pending_invalidation'); + expect(savedObjects[0]).toHaveProperty('type', API_KEY_PENDING_INVALIDATION_TYPE); expect(savedObjects[0]).toHaveProperty('attributes.apiKeyId', '123'); expect(savedObjects[0]).toHaveProperty('attributes.createdAt', expect.any(String)); - expect(savedObjects[1]).toHaveProperty('type', 'api_key_pending_invalidation'); + expect(savedObjects[1]).toHaveProperty('type', API_KEY_PENDING_INVALIDATION_TYPE); expect(savedObjects[1]).toHaveProperty('attributes.apiKeyId', '456'); expect(savedObjects[1]).toHaveProperty('attributes.createdAt', expect.any(String)); }); diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts index 290740a4ddd8b..6711dd3fd6d24 100644 --- a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts +++ b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts @@ -7,6 +7,7 @@ import { Logger, SavedObjectsClientContract } from '@kbn/core/server'; import { withSpan } from '@kbn/apm-utils'; +import { API_KEY_PENDING_INVALIDATION_TYPE } from '..'; export const bulkMarkApiKeysForInvalidation = async ( { apiKeys }: { apiKeys: string[] }, @@ -28,7 +29,7 @@ export const bulkMarkApiKeysForInvalidation = async ( apiKeyId, createdAt: new Date().toISOString(), }, - type: 'api_key_pending_invalidation', + type: API_KEY_PENDING_INVALIDATION_TYPE, })) ); } catch (e) { diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.test.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.test.ts new file mode 100644 index 0000000000000..a7177b2489740 --- /dev/null +++ b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.test.ts @@ -0,0 +1,1120 @@ +/* + * 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 sinon from 'sinon'; +import { loggingSystemMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; +import { API_KEY_PENDING_INVALIDATION_TYPE } from '..'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { + getFindFilter, + getApiKeyIdsToInvalidate, + invalidateApiKeysAndDeletePendingApiKeySavedObject, + runInvalidate, +} from './task'; + +let fakeTimer: sinon.SinonFakeTimers; +const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const logger: ReturnType = loggingSystemMock.createLogger(); +const securityMockStart = securityMock.createStart(); + +const mockInvalidatePendingApiKeyObject1 = { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + attributes: { + apiKeyId: 'abcd====!', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], +}; +const mockInvalidatePendingApiKeyObject2 = { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + attributes: { + apiKeyId: 'xyz!==!', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], +}; + +describe('Invalidate API Keys Task', () => { + beforeAll(() => { + fakeTimer = sinon.useFakeTimers(); + }); + beforeEach(() => { + jest.resetAllMocks(); + }); + afterAll(() => fakeTimer.restore()); + + describe('getFindFilter', () => { + test('should build find filter with just delay', () => { + expect(getFindFilter('2024-04-11T18:40:52.197Z')).toEqual( + `api_key_pending_invalidation.attributes.createdAt <= "2024-04-11T18:40:52.197Z"` + ); + }); + + test('should build find filter with delay and empty excluded SO id array', () => { + expect(getFindFilter('2024-04-11T18:40:52.197Z', [])).toEqual( + `api_key_pending_invalidation.attributes.createdAt <= "2024-04-11T18:40:52.197Z"` + ); + }); + + test('should build find filter with delay and one excluded SO id', () => { + expect(getFindFilter('2024-04-11T18:40:52.197Z', ['abc'])).toEqual( + `api_key_pending_invalidation.attributes.createdAt <= "2024-04-11T18:40:52.197Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:abc"` + ); + }); + + test('should build find filter with delay and multiple excluded SO ids', () => { + expect(getFindFilter('2024-04-11T18:40:52.197Z', ['abc', 'def'])).toEqual( + `api_key_pending_invalidation.attributes.createdAt <= "2024-04-11T18:40:52.197Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:abc" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:def"` + ); + }); + + test('should handle duplicate excluded SO ids', () => { + expect(getFindFilter('2024-04-11T18:40:52.197Z', ['abc', 'abc', 'abc', 'def'])).toEqual( + `api_key_pending_invalidation.attributes.createdAt <= "2024-04-11T18:40:52.197Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:abc" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:def"` + ); + }); + }); + + describe('getApiKeyIdsToInvalidate', () => { + test('should get decrypted api key pending invalidation saved object', async () => { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, + }); + + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + }); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(result).toEqual({ + apiKeyIdsToInvalidate: [ + { id: '1', apiKeyId: 'abcd====!' }, + { id: '2', apiKeyId: 'xyz!==!' }, + ], + apiKeyIdsToExclude: [], + }); + }); + + test('should get decrypted api key pending invalidation saved object when some api keys are still in use', async () => { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcd====!', doc_count: 1 }], + }, + }, + }); + + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + }); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(result).toEqual({ + apiKeyIdsToInvalidate: [{ id: '2', apiKeyId: 'xyz!==!' }], + apiKeyIdsToExclude: [{ id: '1', apiKeyId: 'abcd====!' }], + }); + }); + + test('should throw error if encryptedSavedObjectsClient.getDecryptedAsInternalUser throws error', async () => { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockImplementationOnce(() => { + throw new Error('failfail'); + }); + + await expect( + getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { + apiKeyId: 'encryptedencrypted', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], + }, + { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { + apiKeyId: 'encryptedencrypted', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"failfail"`); + }); + + test('should throw error if malformed savedObjectsClient.find response', async () => { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 10, + // missing aggregations + }); + + const result = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { + apiKeyId: 'encryptedencrypted', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + }); + + expect(result).toEqual({ + apiKeyIdsToInvalidate: [{ id: '1', apiKeyId: 'abcd====!' }], + apiKeyIdsToExclude: [], + }); + }); + + test('should throw error if savedObjectsClient.find throws error', async () => { + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + internalSavedObjectsRepository.find.mockImplementationOnce(() => { + throw new Error('failfail'); + }); + await expect( + getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: { + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { + apiKeyId: 'encryptedencrypted', + createdAt: '2024-04-11T17:08:44.035Z', + }, + references: [], + }, + ], + total: 2, + per_page: 10, + page: 1, + }, + encryptedSavedObjectsClient, + savedObjectsClient: internalSavedObjectsRepository, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"failfail"`); + }); + }); + + describe('invalidateApiKeysAndDeletePendingApiKeySavedObject', () => { + test('should succeed when there are no api keys to invalidate', async () => { + const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate: [], + logger, + savedObjectsClient: internalSavedObjectsRepository, + securityPluginStart: securityMockStart, + }); + expect(total).toEqual(0); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "0"`); + }); + + test('should succeed when there are api keys to invalidate', async () => { + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ + invalidated_api_keys: ['1', '2'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate: [ + { id: '1', apiKeyId: 'abcd====!' }, + { id: '2', apiKeyId: 'xyz!==!' }, + ], + logger, + savedObjectsClient: internalSavedObjectsRepository, + securityPluginStart: securityMockStart, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ + ids: ['abcd====!', 'xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(total).toEqual(2); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); + }); + + test('should handle errors during invalidation', async () => { + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValueOnce({ + invalidated_api_keys: ['1'], + previously_invalidated_api_keys: [], + error_count: 1, + }); + const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate: [ + { id: '1', apiKeyId: 'abcd====!' }, + { id: '2', apiKeyId: 'xyz!==!' }, + ], + logger, + savedObjectsClient: internalSavedObjectsRepository, + securityPluginStart: securityMockStart, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ + ids: ['abcd====!', 'xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + `Failed to invalidate API Keys [ids=\"abcd====!, xyz!==!\"]` + ); + expect(total).toEqual(0); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "0"`); + }); + + test('should handle null security plugin', async () => { + const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate: [ + { id: '1', apiKeyId: 'abcd====!' }, + { id: '2', apiKeyId: 'xyz!==!' }, + ], + logger, + savedObjectsClient: internalSavedObjectsRepository, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).not.toHaveBeenCalled(); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(total).toEqual(2); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); + }); + + test('should handle null result from invalidateAsInternalUser', async () => { + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValueOnce(null); + const total = await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate: [ + { id: '1', apiKeyId: 'abcd====!' }, + { id: '2', apiKeyId: 'xyz!==!' }, + ], + logger, + savedObjectsClient: internalSavedObjectsRepository, + securityPluginStart: securityMockStart, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ + ids: ['abcd====!', 'xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(total).toEqual(2); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); + }); + }); + + describe('runInvalidate', () => { + test('should succeed when there are no API keys to invalidate', async () => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 0, + page: 1, + per_page: 100, + }); + const result = await runInvalidate({ + // @ts-expect-error + config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, + encryptedSavedObjectsClient, + logger, + savedObjectsClient: internalSavedObjectsRepository, + security: securityMockStart, + }); + expect(result).toEqual(0); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(1); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: API_KEY_PENDING_INVALIDATION_TYPE, + filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).not.toHaveBeenCalled(); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).not.toHaveBeenCalled(); + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + }); + + test('should succeed when there are API keys to invalidate', async () => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 100, + page: 1, + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, + }); + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ + invalidated_api_keys: ['1', '2'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + + const result = await runInvalidate({ + // @ts-expect-error + config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, + encryptedSavedObjectsClient, + logger, + savedObjectsClient: internalSavedObjectsRepository, + security: securityMockStart, + }); + expect(result).toEqual(2); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { + type: API_KEY_PENDING_INVALIDATION_TYPE, + filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ + ids: ['abcd====!', 'xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "2"`); + }); + + test('should succeed when there are API keys to invalidate and API keys to exclude', async () => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 100, + page: 1, + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcd====!', doc_count: 1 }], + }, + }, + }); + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ + invalidated_api_keys: ['1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + + const result = await runInvalidate({ + // @ts-expect-error + config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, + encryptedSavedObjectsClient, + logger, + savedObjectsClient: internalSavedObjectsRepository, + security: securityMockStart, + }); + expect(result).toEqual(1); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { + type: API_KEY_PENDING_INVALIDATION_TYPE, + filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledWith({ + ids: ['xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(1); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(logger.debug).toHaveBeenCalledWith(`Total invalidated API keys "1"`); + }); + + test('should succeed when there are only API keys to exclude', async () => { + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 2, + per_page: 100, + page: 1, + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'abcd====!', doc_count: 1 }, + { key: 'xyz!==!', doc_count: 2 }, + ], + }, + }, + }); + + const result = await runInvalidate({ + // @ts-expect-error + config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, + encryptedSavedObjectsClient, + logger, + savedObjectsClient: internalSavedObjectsRepository, + security: securityMockStart, + }); + expect(result).toEqual(0); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: API_KEY_PENDING_INVALIDATION_TYPE, + filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenCalledWith({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).not.toHaveBeenCalled(); + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + }); + + test('should succeed when there are more than PAGE_SIZE API keys to invalidate', async () => { + // first iteration + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 105, + per_page: 100, + page: 1, + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, + }); + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ + invalidated_api_keys: ['1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + // second iteration + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 5, + per_page: 100, + page: 1, + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, + }); + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ + invalidated_api_keys: ['2'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + + const result = await runInvalidate({ + // @ts-expect-error + config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, + encryptedSavedObjectsClient, + logger, + savedObjectsClient: internalSavedObjectsRepository, + security: securityMockStart, + }); + expect(result).toEqual(2); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(4); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(2); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenCalledTimes(2); + + // first iteration + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { + type: API_KEY_PENDING_INVALIDATION_TYPE, + filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenNthCalledWith(1, { + ids: ['abcd====!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(logger.debug).toHaveBeenNthCalledWith(1, `Total invalidated API keys "1"`); + + // second iteration + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { + type: API_KEY_PENDING_INVALIDATION_TYPE, + filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(4, { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenNthCalledWith(2, { + ids: ['xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(logger.debug).toHaveBeenNthCalledWith(2, `Total invalidated API keys "1"`); + }); + + test('should succeed when there are more than PAGE_SIZE API keys to invalidate and API keys to exclude', async () => { + // first iteration + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + { + id: '2', + type: API_KEY_PENDING_INVALIDATION_TYPE, + score: 0, + attributes: { apiKeyId: 'encryptedencrypted', createdAt: '2024-04-11T17:08:44.035Z' }, + references: [], + }, + ], + total: 105, + per_page: 100, + page: 1, + }); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject1 + ); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce( + mockInvalidatePendingApiKeyObject2 + ); + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 2, + page: 1, + per_page: 0, + aggregations: { + apiKeyId: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [{ key: 'abcd====!', doc_count: 1 }], + }, + }, + }); + securityMockStart.authc.apiKeys.invalidateAsInternalUser.mockResolvedValue({ + invalidated_api_keys: ['1'], + previously_invalidated_api_keys: [], + error_count: 0, + }); + // second iteration + internalSavedObjectsRepository.find.mockResolvedValueOnce({ + saved_objects: [], + total: 0, + per_page: 100, + page: 1, + }); + + const result = await runInvalidate({ + // @ts-expect-error + config: { invalidateApiKeysTask: { interval: '1m', removalDelay: '1h' } }, + encryptedSavedObjectsClient, + logger, + savedObjectsClient: internalSavedObjectsRepository, + security: securityMockStart, + }); + expect(result).toEqual(1); + + expect(internalSavedObjectsRepository.find).toHaveBeenCalledTimes(3); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledTimes(2); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenCalledTimes(1); + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledTimes(1); + + // first iteration + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(1, { + type: API_KEY_PENDING_INVALIDATION_TYPE, + filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z"`, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '1' + ); + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenNthCalledWith( + 2, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(2, { + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + perPage: 0, + filter: `ad_hoc_run_params.attributes.apiKeyId: "abcd====!" OR ad_hoc_run_params.attributes.apiKeyId: "xyz!==!"`, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `ad_hoc_run_params.attributes.apiKeyId`, + size: 100, + }, + }, + }, + }); + expect(securityMockStart.authc.apiKeys.invalidateAsInternalUser).toHaveBeenNthCalledWith(1, { + ids: ['xyz!==!'], + }); + expect(internalSavedObjectsRepository.delete).toHaveBeenNthCalledWith( + 1, + API_KEY_PENDING_INVALIDATION_TYPE, + '2' + ); + expect(logger.debug).toHaveBeenNthCalledWith(1, `Total invalidated API keys "1"`); + + // second iteration + expect(internalSavedObjectsRepository.find).toHaveBeenNthCalledWith(3, { + type: API_KEY_PENDING_INVALIDATION_TYPE, + filter: `api_key_pending_invalidation.attributes.createdAt <= "1969-12-31T23:00:00.000Z" AND NOT api_key_pending_invalidation.id: "api_key_pending_invalidation:1"`, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: 100, + }); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.ts index 3f8dac166b583..48eea48246c78 100644 --- a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.ts +++ b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.ts @@ -9,7 +9,6 @@ import { Logger, CoreStart, SavedObjectsFindResponse, - KibanaRequest, SavedObjectsClientContract, } from '@kbn/core/server'; import { EncryptedSavedObjectsClient } from '@kbn/encrypted-saved-objects-plugin/server'; @@ -19,15 +18,22 @@ import { TaskManagerSetupContract, TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; -import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; +import { + AggregationsStringTermsBucketKeys, + AggregationsTermsAggregateBase, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { InvalidateAPIKeyResult } from '../rules_client'; import { AlertingConfig } from '../config'; import { timePeriodBeforeDate } from '../lib/get_cadence'; import { AlertingPluginsStart } from '../plugin'; import { InvalidatePendingApiKey } from '../types'; import { stateSchemaByVersion, emptyState, type LatestTaskStateSchema } from './task_state'; +import { API_KEY_PENDING_INVALIDATION_TYPE } from '..'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { AdHocRunSO } from '../data/ad_hoc_run/types'; const TASK_TYPE = 'alerts_invalidate_api_keys'; +const PAGE_SIZE = 100; export const TASK_ID = `Alerts-${TASK_TYPE}`; const invalidateAPIKeys = async ( @@ -95,25 +101,7 @@ function registerApiKeyInvalidatorTaskDefinition( }); } -function getFakeKibanaRequest(basePath: string) { - const requestHeaders: Record = {}; - return { - headers: requestHeaders, - getBasePath: () => basePath, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - } as unknown as KibanaRequest; -} - -function taskRunner( +export function taskRunner( logger: Logger, coreStartServices: Promise<[CoreStart, AlertingPluginsStart, unknown]>, config: AlertingConfig @@ -124,42 +112,22 @@ function taskRunner( async run() { let totalInvalidated = 0; try { - const [{ savedObjects, http }, { encryptedSavedObjects, security }] = - await coreStartServices; - const savedObjectsClient = savedObjects.getScopedClient( - getFakeKibanaRequest(http.basePath.serverBasePath), - { - includedHiddenTypes: ['api_key_pending_invalidation'], - excludedExtensions: [SECURITY_EXTENSION_ID], - } - ); + const [{ savedObjects }, { encryptedSavedObjects, security }] = await coreStartServices; + const savedObjectsClient = savedObjects.createInternalRepository([ + API_KEY_PENDING_INVALIDATION_TYPE, + AD_HOC_RUN_SAVED_OBJECT_TYPE, + ]); const encryptedSavedObjectsClient = encryptedSavedObjects.getClient({ - includedHiddenTypes: ['api_key_pending_invalidation'], + includedHiddenTypes: [API_KEY_PENDING_INVALIDATION_TYPE], }); - const configuredDelay = config.invalidateApiKeysTask.removalDelay; - const delay = timePeriodBeforeDate(new Date(), configuredDelay).toISOString(); - - let hasApiKeysPendingInvalidation = true; - const PAGE_SIZE = 100; - do { - const apiKeysToInvalidate = await savedObjectsClient.find({ - type: 'api_key_pending_invalidation', - filter: `api_key_pending_invalidation.attributes.createdAt <= "${delay}"`, - page: 1, - sortField: 'createdAt', - sortOrder: 'asc', - perPage: PAGE_SIZE, - }); - totalInvalidated += await invalidateApiKeys( - logger, - savedObjectsClient, - apiKeysToInvalidate, - encryptedSavedObjectsClient, - security - ); - hasApiKeysPendingInvalidation = apiKeysToInvalidate.total > PAGE_SIZE; - } while (hasApiKeysPendingInvalidation); + totalInvalidated = await runInvalidate({ + config, + encryptedSavedObjectsClient, + logger, + savedObjectsClient, + security, + }); const updatedState: LatestTaskStateSchema = { runs: (state.runs || 0) + 1, @@ -189,37 +157,175 @@ function taskRunner( }; } -async function invalidateApiKeys( - logger: Logger, - savedObjectsClient: SavedObjectsClientContract, - apiKeysToInvalidate: SavedObjectsFindResponse, - encryptedSavedObjectsClient: EncryptedSavedObjectsClient, - securityPluginStart?: SecurityPluginStart -) { +interface ApiKeyIdAndSOId { + id: string; + apiKeyId: string; +} + +interface RunInvalidateOpts { + config: AlertingConfig; + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; + security?: SecurityPluginStart; +} +export async function runInvalidate({ + config, + encryptedSavedObjectsClient, + logger, + savedObjectsClient, + security, +}: RunInvalidateOpts) { + const configuredDelay = config.invalidateApiKeysTask.removalDelay; + const delay: string = timePeriodBeforeDate(new Date(), configuredDelay).toISOString(); + + let hasMoreApiKeysPendingInvalidation = true; let totalInvalidated = 0; + const excludedSOIds = new Set(); + + do { + // Query for PAGE_SIZE api keys to invalidate at a time. At the end of each iteration, + // we should have deleted the deletable keys and added keys still in use to the excluded list + const filter = getFindFilter(delay, [...excludedSOIds]); + const apiKeysToInvalidate = await savedObjectsClient.find({ + type: API_KEY_PENDING_INVALIDATION_TYPE, + filter, + page: 1, + sortField: 'createdAt', + sortOrder: 'asc', + perPage: PAGE_SIZE, + }); + + if (apiKeysToInvalidate.total > 0) { + const { apiKeyIdsToExclude, apiKeyIdsToInvalidate } = await getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation: apiKeysToInvalidate, + encryptedSavedObjectsClient, + savedObjectsClient, + }); + apiKeyIdsToExclude.forEach(({ id }) => excludedSOIds.add(id)); + totalInvalidated += await invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate, + logger, + savedObjectsClient, + securityPluginStart: security, + }); + } + + hasMoreApiKeysPendingInvalidation = apiKeysToInvalidate.total > PAGE_SIZE; + } while (hasMoreApiKeysPendingInvalidation); + + return totalInvalidated; +} +interface GetApiKeyIdsToInvalidateOpts { + apiKeySOsPendingInvalidation: SavedObjectsFindResponse; + encryptedSavedObjectsClient: EncryptedSavedObjectsClient; + savedObjectsClient: SavedObjectsClientContract; +} + +interface GetApiKeysToInvalidateResult { + apiKeyIdsToInvalidate: ApiKeyIdAndSOId[]; + apiKeyIdsToExclude: ApiKeyIdAndSOId[]; +} + +export async function getApiKeyIdsToInvalidate({ + apiKeySOsPendingInvalidation, + encryptedSavedObjectsClient, + savedObjectsClient, +}: GetApiKeyIdsToInvalidateOpts): Promise { + // Decrypt the apiKeyId for each pending invalidation SO const apiKeyIds = await Promise.all( - apiKeysToInvalidate.saved_objects.map(async (apiKeyObj) => { - const decryptedApiKey = + apiKeySOsPendingInvalidation.saved_objects.map(async (apiKeyPendingInvalidationSO) => { + const decryptedApiKeyPendingInvalidationObject = await encryptedSavedObjectsClient.getDecryptedAsInternalUser( - 'api_key_pending_invalidation', - apiKeyObj.id + API_KEY_PENDING_INVALIDATION_TYPE, + apiKeyPendingInvalidationSO.id ); - return decryptedApiKey.attributes.apiKeyId; + return { + id: decryptedApiKeyPendingInvalidationObject.id, + apiKeyId: decryptedApiKeyPendingInvalidationObject.attributes.apiKeyId, + }; }) ); - if (apiKeyIds.length > 0) { - const response = await invalidateAPIKeys({ ids: apiKeyIds }, securityPluginStart); + + // Query saved objects index to see if any API keys are in use + const filter = `${apiKeyIds + .map(({ apiKeyId }) => `${AD_HOC_RUN_SAVED_OBJECT_TYPE}.attributes.apiKeyId: "${apiKeyId}"`) + .join(' OR ')}`; + const { aggregations } = await savedObjectsClient.find< + AdHocRunSO, + { apiKeyId: AggregationsTermsAggregateBase } + >({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + filter, + perPage: 0, + namespaces: ['*'], + aggs: { + apiKeyId: { + terms: { + field: `${AD_HOC_RUN_SAVED_OBJECT_TYPE}.attributes.apiKeyId`, + size: PAGE_SIZE, + }, + }, + }, + }); + + const apiKeyIdsInUseBuckets: AggregationsStringTermsBucketKeys[] = + (aggregations?.apiKeyId?.buckets as AggregationsStringTermsBucketKeys[]) ?? []; + + const apiKeyIdsToInvalidate: ApiKeyIdAndSOId[] = []; + const apiKeyIdsToExclude: ApiKeyIdAndSOId[] = []; + apiKeyIds.forEach(({ id, apiKeyId }) => { + if (apiKeyIdsInUseBuckets.find((bucket) => bucket.key === apiKeyId)) { + apiKeyIdsToExclude.push({ id, apiKeyId }); + } else { + apiKeyIdsToInvalidate.push({ id, apiKeyId }); + } + }); + + return { apiKeyIdsToInvalidate, apiKeyIdsToExclude }; +} + +export function getFindFilter(delay: string, excludedSOIds: string[] = []): string { + let filter = `${API_KEY_PENDING_INVALIDATION_TYPE}.attributes.createdAt <= "${delay}"`; + if (excludedSOIds.length > 0) { + const excluded = [...new Set(excludedSOIds)]; + const excludedSOIdFilter = (excluded ?? []).map( + (id: string) => + `NOT ${API_KEY_PENDING_INVALIDATION_TYPE}.id: "${API_KEY_PENDING_INVALIDATION_TYPE}:${id}"` + ); + filter += ` AND ${excludedSOIdFilter.join(' AND ')}`; + } + return filter; +} + +interface InvalidateApiKeysAndDeleteSO { + apiKeyIdsToInvalidate: ApiKeyIdAndSOId[]; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; + securityPluginStart?: SecurityPluginStart; +} + +export async function invalidateApiKeysAndDeletePendingApiKeySavedObject({ + apiKeyIdsToInvalidate, + logger, + savedObjectsClient, + securityPluginStart, +}: InvalidateApiKeysAndDeleteSO) { + let totalInvalidated = 0; + if (apiKeyIdsToInvalidate.length > 0) { + const ids = apiKeyIdsToInvalidate.map(({ apiKeyId }) => apiKeyId); + const response = await invalidateAPIKeys({ ids }, securityPluginStart); if (response.apiKeysEnabled === true && response.result.error_count > 0) { - logger.error(`Failed to invalidate API Keys [ids="${apiKeyIds.join(', ')}"]`); + logger.error(`Failed to invalidate API Keys [ids="${ids.join(', ')}"]`); } else { await Promise.all( - apiKeysToInvalidate.saved_objects.map(async (apiKeyObj) => { + apiKeyIdsToInvalidate.map(async ({ id, apiKeyId }) => { try { - await savedObjectsClient.delete('api_key_pending_invalidation', apiKeyObj.id); + await savedObjectsClient.delete(API_KEY_PENDING_INVALIDATION_TYPE, id); totalInvalidated++; } catch (err) { logger.error( - `Failed to delete invalidated API key "${apiKeyObj.attributes.apiKeyId}". Error: ${err.message}` + `Failed to delete invalidated API key "${apiKeyId}". Error: ${err.message}` ); } }) diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts index 00a9bf7221ef3..6aa8eb9744441 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts @@ -9,10 +9,9 @@ const createAlertingEventLoggerMock = () => { return jest.fn().mockImplementation(() => { return { initialize: jest.fn(), - start: jest.fn(), getEvent: jest.fn(), getStartAndDuration: jest.fn(), - setRuleName: jest.fn(), + addOrUpdateRuleData: jest.fn(), setExecutionSucceeded: jest.fn(), setExecutionFailed: jest.fn(), setMaintenanceWindowIds: jest.fn(), diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts index 62d2a2f14162d..36b00b1d4b6ef 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts @@ -10,13 +10,18 @@ import { IEvent, SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; import { ActionsCompletion } from '@kbn/alerting-state-types'; import { AlertingEventLogger, - RuleContextOpts, + ContextOpts, + Context, + RuleContext, initializeExecuteRecord, - createExecuteStartRecord, createExecuteTimeoutRecord, createAlertRecord, createActionExecuteRecord, updateEvent, + executionType, + initializeExecuteBackfillRecord, + SavedObjects, + updateEventWithRuleData, } from './alerting_event_logger'; import { UntypedNormalizedRuleType } from '../../rule_type_registry'; import { @@ -28,6 +33,8 @@ import { RuleRunMetrics } from '../rule_run_metrics_store'; import { EVENT_LOG_ACTIONS } from '../../plugin'; import { TaskRunnerTimerSpan } from '../../task_runner/task_runner_timer'; import { schema } from '@kbn/config-schema'; +import { RULE_SAVED_OBJECT_TYPE } from '../..'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../saved_objects'; const mockNow = '2020-01-01T02:00:00.000Z'; const eventLogger = eventLoggerMock.create(); @@ -50,19 +57,6 @@ const ruleType: jest.Mocked = { validLegacyConsumers: [], }; -const context: RuleContextOpts = { - ruleId: '123', - ruleType, - consumer: 'test-consumer', - spaceId: 'test-space', - executionId: 'abcd-efgh-ijklmnop', - taskScheduledAt: new Date('2020-01-01T00:00:00.000Z'), - ruleRevision: 0, -}; - -const contextWithScheduleDelay = { ...context, taskScheduleDelay: 7200000 }; -const contextWithName = { ...contextWithScheduleDelay, ruleName: 'my-super-cool-rule' }; - const alert = { action: EVENT_LOG_ACTIONS.activeInstance, id: 'aaabbb', @@ -89,6 +83,13 @@ let runDate: Date; describe('AlertingEventLogger', () => { let alertingEventLogger: AlertingEventLogger; + let ruleData: RuleContext; + let ruleContext: ContextOpts; + let backfillContext: ContextOpts; + let ruleContextWithScheduleDelay: Context; + let backfillContextWithScheduleDelay: Context; + let alertSO: SavedObjects; + let adHocRunSO: SavedObjects; beforeAll(() => { jest.useFakeTimers(); @@ -98,6 +99,34 @@ describe('AlertingEventLogger', () => { beforeEach(() => { jest.resetAllMocks(); + jest.clearAllMocks(); + ruleContext = { + savedObjectId: '123', + savedObjectType: RULE_SAVED_OBJECT_TYPE, + spaceId: 'test-space', + executionId: 'abcd-efgh-ijklmnop', + taskScheduledAt: new Date('2020-01-01T00:00:00.000Z'), + }; + + backfillContext = { + savedObjectId: 'def', + savedObjectType: AD_HOC_RUN_SAVED_OBJECT_TYPE, + spaceId: 'test-space', + executionId: 'wxyz-efgh-ijklmnop', + taskScheduledAt: new Date('2020-01-01T00:00:00.000Z'), + }; + + ruleContextWithScheduleDelay = { ...ruleContext, taskScheduleDelay: 7200000 }; + backfillContextWithScheduleDelay = { ...backfillContext, taskScheduleDelay: 7200000 }; + + ruleData = { + id: '123', + type: ruleType, + consumer: 'test-consumer', + revision: 0, + }; + alertSO = { id: '123', relation: 'primary', type: 'alert', typeId: 'test' }; + adHocRunSO = { id: 'def', relation: 'primary', type: 'ad_hoc_run_params' }; alertingEventLogger = new AlertingEventLogger(eventLogger); }); @@ -106,62 +135,146 @@ describe('AlertingEventLogger', () => { }); describe('initialize()', () => { - test('initialization should succeed if alertingEventLogger has not been initialized', () => { - expect(() => alertingEventLogger.initialize(context)).not.toThrow(); + test('should throw error if alertingEventLogger context is null', () => { + expect(() => + alertingEventLogger.initialize({ + context: null as unknown as ContextOpts, + runDate, + ruleData, + }) + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger already initialized"`); + + expect(() => + alertingEventLogger.initialize({ + context: undefined as unknown as ContextOpts, + runDate, + ruleData, + }) + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger already initialized"`); + + expect(() => + alertingEventLogger.initialize({ + context: null as unknown as ContextOpts, + runDate, + type: executionType.BACKFILL, + }) + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger already initialized"`); + + expect(() => + alertingEventLogger.initialize({ + context: undefined as unknown as ContextOpts, + runDate, + type: executionType.BACKFILL, + }) + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger already initialized"`); }); - test('initialization should fail if alertingEventLogger has already been initialized', () => { - alertingEventLogger.initialize(context); - expect(() => alertingEventLogger.initialize(context)).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger already initialized"` - ); + test('standard initialization should succeed if alertingEventLogger has not been initialized', () => { + expect(() => + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }) + ).not.toThrow(); + expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); }); - }); - describe('start()', () => { - test('should throw error if alertingEventLogger has not been initialized', () => { - expect(() => alertingEventLogger.start(runDate)).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` - ); + test('backfill initialization should succeed if alertingEventLogger has not been initialized', () => { + expect(() => + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }) + ).not.toThrow(); + expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); }); - test('should throw error if alertingEventLogger rule context is null', () => { - alertingEventLogger.initialize(null as unknown as RuleContextOpts); - expect(() => alertingEventLogger.start(runDate)).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` - ); + test('standard initialization should fail if alertingEventLogger has already been initialized', () => { + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + expect(() => + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }) + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger already initialized"`); }); - test('should throw error if alertingEventLogger rule context is undefined', () => { - alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); - expect(() => alertingEventLogger.start(runDate)).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` - ); + test('backfill initialization should fail if alertingEventLogger has already been initialized', () => { + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); + expect(() => + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }) + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger already initialized"`); }); - test('should call eventLogger "startTiming" and "logEvent"', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + test('standard initialization should fail if ruleData is not provided', () => { + expect(() => + alertingEventLogger.initialize({ context: ruleContext, runDate }) + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger requires rule data"`); + expect(eventLogger.startTiming).not.toHaveBeenCalled(); + }); + + test('standard initialization should call eventLogger.logEvent', () => { + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); + + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent).toHaveBeenCalledWith({ + ...event, + event: { + ...event.event, + action: EVENT_LOG_ACTIONS.executeStart, + start: runDate.toISOString(), + }, + message: `rule execution start: "${ruleData.id}"`, + }); - expect(eventLogger.startTiming).toHaveBeenCalledWith( - initializeExecuteRecord(contextWithScheduleDelay), - new Date(mockNow) - ); - expect(eventLogger.logEvent).toHaveBeenCalledWith( - createExecuteStartRecord(contextWithScheduleDelay, new Date(mockNow)) - ); + expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); + expect(eventLogger.startTiming).toHaveBeenCalledWith(event, new Date(mockNow)); + }); + + test('backfill initialization should not call eventLogger.logEvent', () => { + const event = initializeExecuteBackfillRecord(backfillContextWithScheduleDelay, [adHocRunSO]); + + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); + + expect(eventLogger.logEvent).not.toHaveBeenCalled(); + expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); + expect(eventLogger.startTiming).toHaveBeenCalledWith(event, new Date(mockNow)); + }); + + test('standard initialization should correctly initialize the "execute" event', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + }, + }); }); - test('should initialize the "execute" event', () => { + test('backfill initialization should correctly initialize the "execute" event', () => { mockEventLoggerStartTiming(); - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteBackfillRecord(backfillContextWithScheduleDelay, [adHocRunSO]); expect(alertingEventLogger.getEvent()).toEqual({ ...event, event: { @@ -172,26 +285,30 @@ describe('AlertingEventLogger', () => { }); }); - describe('setRuleName()', () => { + describe('addOrUpdateRuleData()', () => { test('should throw error if alertingEventLogger has not been initialized', () => { - expect(() => alertingEventLogger.setRuleName('')).toThrowErrorMatchingInlineSnapshot( + expect(() => alertingEventLogger.addOrUpdateRuleData({})).toThrowErrorMatchingInlineSnapshot( `"AlertingEventLogger not initialized"` ); }); - test('should throw error if event is null', () => { - alertingEventLogger.initialize(context); - expect(() => alertingEventLogger.setRuleName('')).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` - ); + test('should throw error if updating rule data that has not been initialized', () => { + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); + + expect(() => + alertingEventLogger.addOrUpdateRuleData({ name: 'new-name' }) + ).toThrowErrorMatchingInlineSnapshot(`"Cannot update rule data before it is initialized"`); }); - test('should update event with rule name correctly', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); - alertingEventLogger.setRuleName('my-super-cool-rule'); + test('should update standard event with rule name correctly', () => { + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + alertingEventLogger.addOrUpdateRuleData({ name: 'my-super-cool-rule' }); - const event = initializeExecuteRecord(contextWithScheduleDelay); expect(alertingEventLogger.getEvent()).toEqual({ ...event, rule: { @@ -200,6 +317,72 @@ describe('AlertingEventLogger', () => { }, }); }); + + test('should update standard event with rule consumer correctly', () => { + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + alertingEventLogger.addOrUpdateRuleData({ consumer: 'my-new-consumer' }); + + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + consumer: 'my-new-consumer', + }, + }, + }, + }); + }); + + test('should update backfill event with rule data correctly', () => { + const event = initializeExecuteBackfillRecord(backfillContextWithScheduleDelay, [adHocRunSO]); + + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); + alertingEventLogger.addOrUpdateRuleData({ + id: 'bbb', + type: ruleType, + name: 'rule-name', + revision: 10, + consumer: 'my-new-consumer', + }); + + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + rule: { + ...event.rule, + id: 'bbb', + name: 'rule-name', + category: 'test', + license: 'basic', + ruleset: 'alerts', + }, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + consumer: 'my-new-consumer', + revision: 10, + rule_type_id: 'test', + }, + }, + saved_objects: [ + // @ts-ignore + ...event.kibana?.saved_objects, + { id: 'bbb', type: 'alert', type_id: 'test' }, + ], + }, + }); + }); }); describe('setExecutionSucceeded()', () => { @@ -209,22 +392,14 @@ describe('AlertingEventLogger', () => { ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); }); - test('should throw error if event is null', () => { - alertingEventLogger.initialize(context); - expect(() => - alertingEventLogger.setExecutionSucceeded('') - ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); - }); - - test('should update execute event correctly', () => { + test('should update execute event correctly for standard executions', () => { mockEventLoggerStartTiming(); - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); - alertingEventLogger.setRuleName('my-super-cool-rule'); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + alertingEventLogger.addOrUpdateRuleData({ name: 'my-super-cool-rule' }); alertingEventLogger.setExecutionSucceeded('success!'); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); expect(alertingEventLogger.getEvent()).toEqual({ ...event, event: { @@ -245,6 +420,63 @@ describe('AlertingEventLogger', () => { message: 'success!', }); }); + + test('should update execute event correctly for backfill executions', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); + alertingEventLogger.addOrUpdateRuleData({ + id: 'bbb', + type: ruleType, + name: 'rule-name', + revision: 10, + consumer: 'my-new-consumer', + }); + alertingEventLogger.setExecutionSucceeded('success!'); + + const event = initializeExecuteBackfillRecord(backfillContextWithScheduleDelay, [adHocRunSO]); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + outcome: 'success', + }, + rule: { + ...event.rule, + id: 'bbb', + name: 'rule-name', + category: 'test', + license: 'basic', + ruleset: 'alerts', + }, + message: 'success!', + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + consumer: 'my-new-consumer', + revision: 10, + rule_type_id: 'test', + }, + }, + alerting: { + outcome: 'success', + }, + saved_objects: [ + // @ts-ignore + ...event.kibana?.saved_objects, + { id: 'bbb', type: 'alert', type_id: 'test' }, + ], + }, + }); + }); }); describe('setExecutionFailed()', () => { @@ -254,21 +486,51 @@ describe('AlertingEventLogger', () => { ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); }); - test('should throw error if event is null', () => { - alertingEventLogger.initialize(context); - expect(() => - alertingEventLogger.setExecutionFailed('', '') - ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + test('should update execute event correctly for standard executions', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + alertingEventLogger.setExecutionFailed('rule failed!', 'something went wrong!'); + + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + outcome: 'failure', + }, + error: { + message: 'something went wrong!', + }, + kibana: { + ...event.kibana, + alerting: { + outcome: 'failure', + }, + }, + message: 'rule failed!', + }); }); - test('should update execute event correctly', () => { + test('should update execute event correctly for backfill executions if error occurs after rule data is set', () => { mockEventLoggerStartTiming(); - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); + alertingEventLogger.addOrUpdateRuleData({ + id: 'bbb', + type: ruleType, + name: 'rule-name', + revision: 10, + consumer: 'my-new-consumer', + }); alertingEventLogger.setExecutionFailed('rule failed!', 'something went wrong!'); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteBackfillRecord(backfillContextWithScheduleDelay, [adHocRunSO]); expect(alertingEventLogger.getEvent()).toEqual({ ...event, event: { @@ -276,16 +538,69 @@ describe('AlertingEventLogger', () => { start: new Date(mockNow).toISOString(), outcome: 'failure', }, + rule: { + ...event.rule, + id: 'bbb', + name: 'rule-name', + category: 'test', + license: 'basic', + ruleset: 'alerts', + }, error: { message: 'something went wrong!', }, + message: 'rule failed!', kibana: { ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + consumer: 'my-new-consumer', + revision: 10, + rule_type_id: 'test', + }, + }, alerting: { outcome: 'failure', }, + saved_objects: [ + // @ts-ignore + ...event.kibana?.saved_objects, + { id: 'bbb', type: 'alert', type_id: 'test' }, + ], + }, + }); + }); + + test('should update execute event correctly for backfill executions if error occurs before rule data is set', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); + alertingEventLogger.setExecutionFailed('rule failed!', 'something went wrong!'); + + const event = initializeExecuteBackfillRecord(backfillContextWithScheduleDelay, [adHocRunSO]); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + outcome: 'failure', + }, + error: { + message: 'something went wrong!', }, message: 'rule failed!', + kibana: { + ...event.kibana, + alerting: { + outcome: 'failure', + }, + }, }); }); }); @@ -297,19 +612,11 @@ describe('AlertingEventLogger', () => { ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); }); - test('should throw error if event is null', () => { - alertingEventLogger.initialize(context); - expect(() => - alertingEventLogger.setMaintenanceWindowIds([]) - ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); - }); - it('should update event maintenance window IDs correctly', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.setMaintenanceWindowIds([]); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); expect(alertingEventLogger.getEvent()).toEqual({ ...event, kibana: { @@ -336,33 +643,69 @@ describe('AlertingEventLogger', () => { }); describe('logTimeout()', () => { - test('should throw error if alertingEventLogger has not been initialized', () => { - expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` - ); - }); + test('should log timeout event for standard execution', () => { + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + alertingEventLogger.logTimeout(); - test('should throw error if alertingEventLogger rule context is null', () => { - alertingEventLogger.initialize(null as unknown as RuleContextOpts); - expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` + const event = createExecuteTimeoutRecord( + ruleContext, + [alertSO], + executionType.STANDARD, + ruleData ); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); }); - test('should throw error if alertingEventLogger rule context is undefined', () => { - alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); - expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` + test('should throw error if backfill fields provided when execution type is not backfill', () => { + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + expect(() => + alertingEventLogger.logTimeout({ backfill: { id: 'abc' } }) + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot set backfill fields for non-backfill event log doc"` ); }); - test('should log timeout event', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.logTimeout(); + test('should log timeout event for backfill execution if called before rule data is set', () => { + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); + alertingEventLogger.logTimeout({ + backfill: { + id: 'abc', + start: '2024-03-13T00:00:00.000Z', + interval: '1h', + }, + }); - const event = createExecuteTimeoutRecord(contextWithName); + const event = createExecuteTimeoutRecord( + backfillContextWithScheduleDelay, + [adHocRunSO], + executionType.BACKFILL + ); - expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + expect(eventLogger.logEvent).toHaveBeenCalledWith({ + ...event, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + execution: { + ...event.kibana?.alert?.rule?.execution, + backfill: { + id: 'abc', + start: '2024-03-13T00:00:00.000Z', + interval: '1h', + }, + }, + }, + }, + }, + }); }); }); @@ -373,27 +716,68 @@ describe('AlertingEventLogger', () => { ); }); - test('should throw error if alertingEventLogger rule context is null', () => { - alertingEventLogger.initialize(null as unknown as RuleContextOpts); - expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` - ); + test('should correct log alerts for standard executions', () => { + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + alertingEventLogger.logAlert(alert); + + const event = createAlertRecord(ruleContext, ruleData, [alertSO], alert); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); }); - test('should throw error if alertingEventLogger rule context is undefined', () => { - alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + test('should throw if trying to log alerts for backfill executions when no rule data is set', () => { + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( `"AlertingEventLogger not initialized"` ); }); - test('should log timeout event', () => { - alertingEventLogger.initialize(context); + test('should correct log alerts for backfill executions', () => { + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); + alertingEventLogger.addOrUpdateRuleData({ + id: 'bbb', + type: ruleType, + name: 'rule-name', + revision: 10, + consumer: 'my-new-consumer', + }); alertingEventLogger.logAlert(alert); - const event = createAlertRecord(contextWithName, alert); + const event = createAlertRecord( + backfillContext, + ruleData, + [adHocRunSO, { id: 'bbb', type: 'alert', typeId: 'test' }], + alert + ); - expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + expect(eventLogger.logEvent).toHaveBeenCalledWith({ + ...event, + rule: { + ...event.rule, + id: 'bbb', + name: 'rule-name', + }, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + consumer: 'my-new-consumer', + revision: 10, + rule_type_id: 'test', + }, + }, + }, + }); }); }); @@ -404,25 +788,22 @@ describe('AlertingEventLogger', () => { ); }); - test('should throw error if alertingEventLogger rule context is null', () => { - alertingEventLogger.initialize(null as unknown as RuleContextOpts); - expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` - ); - }); - - test('should throw error if alertingEventLogger rule context is undefined', () => { - alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + test('should throw if trying to log action event when no rule data is set', () => { + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( `"AlertingEventLogger not initialized"` ); }); - test('should log timeout event', () => { - alertingEventLogger.initialize(context); + test('should log action event', () => { + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.logAction(action); - const event = createActionExecuteRecord(contextWithName, action); + const event = createActionExecuteRecord(ruleContext, ruleData, [alertSO], action); expect(eventLogger.logEvent).toHaveBeenCalledWith(event); }); @@ -435,45 +816,30 @@ describe('AlertingEventLogger', () => { ); }); - test('should throw error if alertingEventLogger rule context is null', () => { - alertingEventLogger.initialize(null as unknown as RuleContextOpts); - expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` - ); - }); - - test('should throw error if alertingEventLogger rule context is undefined', () => { - alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); - expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` - ); - }); - - test('should throw error if event is null', () => { - alertingEventLogger.initialize(context); - expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( - `"AlertingEventLogger not initialized"` + test('should throw error if backfill fields provided when execution type is not backfill', () => { + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); + expect(() => + alertingEventLogger.done({ backfill: { id: 'abc' } }) + ).toThrowErrorMatchingInlineSnapshot( + `"Cannot set backfill fields for non-backfill event log doc"` ); }); test('should log event if no status or metrics are provided', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({}); - const event = initializeExecuteRecord(contextWithScheduleDelay); - + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); expect(eventLogger.logEvent).toHaveBeenCalledWith(event); }); test('should set fields from execution status if provided', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ status: { lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), status: 'active' }, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); const loggedEvent = { ...event, kibana: { @@ -488,8 +854,7 @@ describe('AlertingEventLogger', () => { }); test('should set fields from execution status if execution status is error', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ status: { lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), @@ -501,7 +866,7 @@ describe('AlertingEventLogger', () => { }, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); const loggedEvent = { ...event, event: { @@ -527,8 +892,7 @@ describe('AlertingEventLogger', () => { }); test('should set fields from execution status if execution status is error and uses "unknown" if no reason is provided', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ status: { lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), @@ -540,7 +904,7 @@ describe('AlertingEventLogger', () => { }, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); const loggedEvent = { ...event, event: { @@ -566,8 +930,7 @@ describe('AlertingEventLogger', () => { }); test('should set fields from execution status if execution status is error and does not overwrite existing error message', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ status: { lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), @@ -579,7 +942,7 @@ describe('AlertingEventLogger', () => { }, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); alertingEventLogger.setExecutionFailed( 'i am an existing error message', 'i am an existing error message!' @@ -609,8 +972,7 @@ describe('AlertingEventLogger', () => { }); test('should set fields from execution status if execution status is warning', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ status: { lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), @@ -622,7 +984,7 @@ describe('AlertingEventLogger', () => { }, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); const loggedEvent = { ...event, event: { @@ -644,8 +1006,7 @@ describe('AlertingEventLogger', () => { }); test('should set fields from execution status if execution status is warning and uses "unknown" if no reason is provided', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ status: { lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), @@ -657,7 +1018,7 @@ describe('AlertingEventLogger', () => { }, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); const loggedEvent = { ...event, event: { @@ -679,8 +1040,7 @@ describe('AlertingEventLogger', () => { }); test('should set fields from execution status if execution status is warning and uses existing message if no message is provided', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ status: { lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), @@ -692,7 +1052,7 @@ describe('AlertingEventLogger', () => { }, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); alertingEventLogger.setExecutionSucceeded('success!'); const loggedEvent = { ...event, @@ -715,9 +1075,72 @@ describe('AlertingEventLogger', () => { expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); }); + test('should set fields from backfill if provided', () => { + alertingEventLogger.initialize({ + context: backfillContext, + runDate, + type: executionType.BACKFILL, + }); + alertingEventLogger.addOrUpdateRuleData({ + id: 'bbb', + type: ruleType, + name: 'rule-name', + revision: 10, + consumer: 'my-new-consumer', + }); + + alertingEventLogger.done({ + backfill: { + id: 'abc', + start: '2024-03-13T00:00:00.000Z', + interval: '1h', + }, + }); + + const event = initializeExecuteBackfillRecord(backfillContextWithScheduleDelay, [adHocRunSO]); + const loggedEvent = { + ...event, + rule: { + ...event.rule, + id: 'bbb', + name: 'rule-name', + category: 'test', + license: 'basic', + ruleset: 'alerts', + }, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + consumer: 'my-new-consumer', + revision: 10, + rule_type_id: 'test', + execution: { + ...event.kibana?.alert?.rule?.execution, + backfill: { + id: 'abc', + start: '2024-03-13T00:00:00.000Z', + interval: '1h', + }, + }, + }, + }, + saved_objects: [ + // @ts-ignore + ...event.kibana?.saved_objects, + { id: 'bbb', type: 'alert', type_id: 'test' }, + ], + }, + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + test('should set fields from execution metrics if provided', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ metrics: { numberOfTriggeredActions: 1, @@ -735,7 +1158,7 @@ describe('AlertingEventLogger', () => { }, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); const loggedEvent = { ...event, kibana: { @@ -770,8 +1193,7 @@ describe('AlertingEventLogger', () => { }); test('should set fields from execution timings if provided', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ timings: { [TaskRunnerTimerSpan.StartTaskRun]: 10, @@ -785,7 +1207,7 @@ describe('AlertingEventLogger', () => { }, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); const loggedEvent = { ...event, kibana: { @@ -817,8 +1239,7 @@ describe('AlertingEventLogger', () => { }); test('should set fields from execution metrics and timings if both provided', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ metrics: { numberOfTriggeredActions: 1, @@ -846,7 +1267,7 @@ describe('AlertingEventLogger', () => { }, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); const loggedEvent = { ...event, kibana: { @@ -889,13 +1310,12 @@ describe('AlertingEventLogger', () => { }); test('should set fields to 0 execution metrics are provided but undefined', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.done({ metrics: {} as unknown as RuleRunMetrics, }); - const event = initializeExecuteRecord(contextWithScheduleDelay); + const event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); const loggedEvent = { ...event, kibana: { @@ -930,8 +1350,7 @@ describe('AlertingEventLogger', () => { }); test('overwrites the message when the final status is error', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.setExecutionSucceeded('success message'); expect(alertingEventLogger.getEvent()!.message).toBe('success message'); @@ -948,8 +1367,7 @@ describe('AlertingEventLogger', () => { }); test('does not overwrites the message when there is already a failure message', () => { - alertingEventLogger.initialize(context); - alertingEventLogger.start(runDate); + alertingEventLogger.initialize({ context: ruleContext, runDate, ruleData }); alertingEventLogger.setExecutionFailed('first failure message', 'failure error message'); expect(alertingEventLogger.getEvent()!.message).toBe('first failure message'); @@ -970,376 +1388,610 @@ describe('AlertingEventLogger', () => { }); }); -describe('createExecuteStartRecord', () => { - test('should create execute-start record', () => { - const executeRecord = initializeExecuteRecord(contextWithScheduleDelay); - const record = createExecuteStartRecord(contextWithScheduleDelay); +describe('helper functions', () => { + let ruleData: RuleContext; + let ruleDataWithName: RuleContext; + let ruleContext: ContextOpts; + let backfillContext: ContextOpts; + let ruleContextWithScheduleDelay: Context; + let backfillContextWithScheduleDelay: Context; + let alertSO: SavedObjects; + let adHocRunSO: SavedObjects; + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + ruleContext = { + savedObjectId: '123', + savedObjectType: RULE_SAVED_OBJECT_TYPE, + spaceId: 'test-space', + executionId: 'abcd-efgh-ijklmnop', + taskScheduledAt: new Date('2020-01-01T00:00:00.000Z'), + }; + + backfillContext = { + savedObjectId: 'def', + savedObjectType: AD_HOC_RUN_SAVED_OBJECT_TYPE, + spaceId: 'test-space', + executionId: 'wxyz-efgh-ijklmnop', + taskScheduledAt: new Date('2020-01-01T00:00:00.000Z'), + }; + + ruleContextWithScheduleDelay = { ...ruleContext, taskScheduleDelay: 7200000 }; + backfillContextWithScheduleDelay = { ...backfillContext, taskScheduleDelay: 7200000 }; + + ruleData = { + id: '123', + type: ruleType, + consumer: 'test-consumer', + revision: 0, + }; + ruleDataWithName = { ...ruleData, name: 'my-super-cool-rule' }; + alertSO = { id: '123', relation: 'primary', type: 'alert', typeId: 'test' }; + adHocRunSO = { id: 'def', relation: 'primary', type: 'ad_hoc_run_params' }; + }); - expect(record).toEqual({ - ...executeRecord, - event: { - ...executeRecord.event, - action: 'execute-start', - }, - message: `rule execution start: "123"`, + describe('initializeExecuteRecord', () => { + test('should populate initial set of fields in event log record', () => { + const record = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.kibana?.task).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([ruleData.type?.producer]); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(ruleData.type?.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(ruleData.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual( + ruleContextWithScheduleDelay.executionId + ); + expect(record.kibana?.saved_objects).toEqual([ + { + id: alertSO.id, + type: alertSO.type, + type_id: alertSO.typeId, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([ruleContextWithScheduleDelay.spaceId]); + expect(record.kibana?.task?.scheduled).toEqual( + ruleContextWithScheduleDelay.taskScheduledAt.toISOString() + ); + expect(record.kibana?.task?.schedule_delay).toEqual( + ruleContextWithScheduleDelay.taskScheduleDelay * 1000000 + ); + expect(record?.rule?.id).toEqual(ruleData.id); + expect(record?.rule?.license).toEqual(ruleData.type?.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(ruleData.type?.id); + expect(record?.rule?.ruleset).toEqual(ruleData.type?.producer); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.alerting).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.rule?.name).toBeUndefined(); + expect(record?.message).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); }); - }); - test('should create execute-start record with given start time', () => { - const executeRecord = initializeExecuteRecord(contextWithScheduleDelay); - const record = createExecuteStartRecord( - contextWithScheduleDelay, - new Date('2022-01-01T02:00:00.000Z') - ); - - expect(record).toEqual({ - ...executeRecord, - event: { - ...executeRecord.event, - action: 'execute-start', - start: '2022-01-01T02:00:00.000Z', - }, - message: `rule execution start: "123"`, + test('should populate initial set of fields in event log record when execution type is BACKFILL', () => { + const record = initializeExecuteBackfillRecord(backfillContextWithScheduleDelay, [ + adHocRunSO, + ]); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.kibana?.task).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute-backfill'); + expect(record.event?.kind).toEqual('alert'); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual( + backfillContextWithScheduleDelay.executionId + ); + expect(record.kibana?.saved_objects).toEqual([ + { + id: adHocRunSO.id, + type: adHocRunSO.type, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([backfillContextWithScheduleDelay.spaceId]); + expect(record.kibana?.task?.scheduled).toEqual( + backfillContextWithScheduleDelay.taskScheduledAt.toISOString() + ); + expect(record.kibana?.task?.schedule_delay).toEqual( + backfillContextWithScheduleDelay.taskScheduleDelay * 1000000 + ); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.alerting).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.rule?.name).toBeUndefined(); + expect(record?.message).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + expect(record.kibana?.alert?.rule?.rule_type_id).toBeUndefined(); + expect(record.kibana?.alert?.rule?.consumer).toBeUndefined(); + expect(record?.rule?.id).toBeUndefined(); + expect(record?.rule?.license).toBeUndefined(); + expect(record?.rule?.category).toBeUndefined(); + expect(record?.rule?.ruleset).toBeUndefined(); }); }); -}); -describe('initializeExecuteRecord', () => { - test('should populate initial set of fields in event log record', () => { - const record = initializeExecuteRecord(contextWithScheduleDelay); - - expect(record.event).toBeDefined(); - expect(record.kibana).toBeDefined(); - expect(record.kibana?.alert).toBeDefined(); - expect(record.kibana?.alert?.rule).toBeDefined(); - expect(record.kibana?.alert?.rule?.execution).toBeDefined(); - expect(record.kibana?.saved_objects).toBeDefined(); - expect(record.kibana?.space_ids).toBeDefined(); - expect(record.kibana?.task).toBeDefined(); - expect(record.rule).toBeDefined(); - - // these fields should be explicitly set - expect(record.event?.action).toEqual('execute'); - expect(record.event?.kind).toEqual('alert'); - expect(record.event?.category).toEqual([contextWithScheduleDelay.ruleType.producer]); - expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithScheduleDelay.ruleType.id); - expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithScheduleDelay.consumer); - expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual( - contextWithScheduleDelay.executionId - ); - expect(record.kibana?.saved_objects).toEqual([ - { - id: contextWithScheduleDelay.ruleId, - type: 'alert', - type_id: contextWithScheduleDelay.ruleType.id, - rel: SAVED_OBJECT_REL_PRIMARY, - }, - ]); - expect(record.kibana?.space_ids).toEqual([contextWithScheduleDelay.spaceId]); - expect(record.kibana?.task?.scheduled).toEqual( - contextWithScheduleDelay.taskScheduledAt.toISOString() - ); - expect(record.kibana?.task?.schedule_delay).toEqual( - contextWithScheduleDelay.taskScheduleDelay * 1000000 - ); - expect(record?.rule?.id).toEqual(contextWithScheduleDelay.ruleId); - expect(record?.rule?.license).toEqual(contextWithScheduleDelay.ruleType.minimumLicenseRequired); - expect(record?.rule?.category).toEqual(contextWithScheduleDelay.ruleType.id); - expect(record?.rule?.ruleset).toEqual(contextWithScheduleDelay.ruleType.producer); - - // these fields should not be set by this function - expect(record['@timestamp']).toBeUndefined(); - expect(record.event?.provider).toBeUndefined(); - expect(record.event?.start).toBeUndefined(); - expect(record.event?.outcome).toBeUndefined(); - expect(record.event?.end).toBeUndefined(); - expect(record.event?.duration).toBeUndefined(); - expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); - expect(record.kibana?.alerting).toBeUndefined(); - expect(record.kibana?.server_uuid).toBeUndefined(); - expect(record.kibana?.version).toBeUndefined(); - expect(record?.rule?.name).toBeUndefined(); - expect(record?.message).toBeUndefined(); - expect(record?.ecs).toBeUndefined(); - }); -}); + describe('createExecuteTimeoutRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createExecuteTimeoutRecord( + ruleContextWithScheduleDelay, + [alertSO], + executionType.STANDARD, + ruleDataWithName + ); -describe('createExecuteTimeoutRecord', () => { - test('should populate expected fields in event log record', () => { - const record = createExecuteTimeoutRecord(contextWithName); - - expect(record.event).toBeDefined(); - expect(record.kibana).toBeDefined(); - expect(record.kibana?.alert).toBeDefined(); - expect(record.kibana?.alert?.rule).toBeDefined(); - expect(record.kibana?.alert?.rule?.execution).toBeDefined(); - expect(record.kibana?.saved_objects).toBeDefined(); - expect(record.kibana?.space_ids).toBeDefined(); - expect(record.rule).toBeDefined(); - - // these fields should be explicitly set - expect(record.event?.action).toEqual('execute-timeout'); - expect(record.event?.kind).toEqual('alert'); - expect(record.message).toEqual( - `rule: test:123: 'my-super-cool-rule' execution cancelled due to timeout - exceeded rule type timeout of 1m` - ); - expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); - expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); - expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); - expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); - expect(record.kibana?.saved_objects).toEqual([ - { - id: contextWithName.ruleId, - type: 'alert', - type_id: contextWithName.ruleType.id, - rel: SAVED_OBJECT_REL_PRIMARY, - }, - ]); - expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); - expect(record?.rule?.id).toEqual(contextWithName.ruleId); - expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); - expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); - expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); - expect(record?.rule?.name).toEqual(contextWithName.ruleName); - - // these fields should not be set by this function - expect(record['@timestamp']).toBeUndefined(); - expect(record.event?.provider).toBeUndefined(); - expect(record.event?.start).toBeUndefined(); - expect(record.event?.outcome).toBeUndefined(); - expect(record.event?.end).toBeUndefined(); - expect(record.event?.duration).toBeUndefined(); - expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); - expect(record.kibana?.alerting).toBeUndefined(); - expect(record.kibana?.server_uuid).toBeUndefined(); - expect(record.kibana?.task).toBeUndefined(); - expect(record.kibana?.version).toBeUndefined(); - expect(record?.ecs).toBeUndefined(); + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute-timeout'); + expect(record.event?.kind).toEqual('alert'); + expect(record.message).toEqual( + `rule: test:123: 'my-super-cool-rule' execution cancelled due to timeout - exceeded rule type timeout of 1m` + ); + expect(record.event?.category).toEqual([ruleDataWithName.type?.producer]); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(ruleDataWithName.type?.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(ruleDataWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual( + ruleContextWithScheduleDelay.executionId + ); + expect(record.kibana?.saved_objects).toEqual([ + { + id: alertSO.id, + type: alertSO.type, + type_id: alertSO.typeId, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([ruleContextWithScheduleDelay.spaceId]); + expect(record?.rule?.id).toEqual(ruleDataWithName.id); + expect(record?.rule?.license).toEqual(ruleDataWithName.type?.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(ruleDataWithName.type?.id); + expect(record?.rule?.ruleset).toEqual(ruleDataWithName.type?.producer); + expect(record?.rule?.name).toEqual(ruleDataWithName.name); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.alerting).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); }); -}); -describe('createAlertRecord', () => { - test('should populate expected fields in event log record', () => { - const record = createAlertRecord(contextWithName, alert); - - // these fields should be explicitly set - expect(record.event?.action).toEqual('active-instance'); - expect(record.event?.kind).toEqual('alert'); - expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); - expect(record.event?.start).toEqual(alert.state.start); - expect(record.event?.end).toEqual(alert.state.end); - expect(record.event?.duration).toEqual(alert.state.duration); - expect(record.message).toEqual( - `.test-rule-type:123: 'my rule' active alert: 'aaabbb' in actionGroup: 'aGroup';` - ); - expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); - expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); - expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); - expect(record.kibana?.alert?.maintenance_window_ids).toEqual(alert.maintenanceWindowIds); - expect(record.kibana?.alerting?.instance_id).toEqual(alert.id); - expect(record.kibana?.alerting?.action_group_id).toEqual(alert.group); - expect(record.kibana?.saved_objects).toEqual([ - { - id: contextWithName.ruleId, - type: 'alert', - type_id: contextWithName.ruleType.id, - rel: SAVED_OBJECT_REL_PRIMARY, - }, - ]); - expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); - expect(record?.rule?.id).toEqual(contextWithName.ruleId); - expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); - expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); - expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); - expect(record?.rule?.name).toEqual(contextWithName.ruleName); - - // these fields should not be set by this function - expect(record['@timestamp']).toBeUndefined(); - expect(record.event?.provider).toBeUndefined(); - expect(record.event?.outcome).toBeUndefined(); - expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); - expect(record.kibana?.alert?.uuid).toBe(alert.uuid); - expect(record.kibana?.server_uuid).toBeUndefined(); - expect(record.kibana?.task).toBeUndefined(); - expect(record.kibana?.version).toBeUndefined(); - expect(record?.ecs).toBeUndefined(); - }); -}); + describe('createAlertRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createAlertRecord( + ruleContextWithScheduleDelay, + ruleDataWithName, + [alertSO], + alert + ); -describe('createActionExecuteRecord', () => { - test('should populate expected fields in event log record', () => { - const record = createActionExecuteRecord(contextWithName, action); - - expect(record.event).toBeDefined(); - expect(record.kibana).toBeDefined(); - expect(record.kibana?.alert).toBeDefined(); - expect(record.kibana?.alert?.rule).toBeDefined(); - expect(record.kibana?.alert?.rule?.execution).toBeDefined(); - expect(record.kibana?.saved_objects).toBeDefined(); - expect(record.kibana?.space_ids).toBeDefined(); - expect(record.rule).toBeDefined(); - - // these fields should be explicitly set - expect(record.event?.action).toEqual('execute-action'); - expect(record.event?.kind).toEqual('alert'); - expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); - expect(record.message).toEqual( - `alert: test:123: 'my-super-cool-rule' instanceId: '123' scheduled actionGroup: 'aGroup' action: .email:abc` - ); - expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); - expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); - expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); - expect(record.kibana?.alerting?.instance_id).toEqual(action.alertId); - expect(record.kibana?.alerting?.action_group_id).toEqual(action.alertGroup); - expect(record.kibana?.saved_objects).toEqual([ - { - id: contextWithName.ruleId, - type: 'alert', - type_id: contextWithName.ruleType.id, - rel: SAVED_OBJECT_REL_PRIMARY, - }, - { - id: action.id, - type: 'action', - type_id: action.typeId, - }, - ]); - expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); - expect(record?.rule?.id).toEqual(contextWithName.ruleId); - expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); - expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); - expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); - expect(record?.rule?.name).toEqual(contextWithName.ruleName); - - // these fields should not be set by this function - expect(record['@timestamp']).toBeUndefined(); - expect(record.event?.provider).toBeUndefined(); - expect(record.event?.start).toBeUndefined(); - expect(record.event?.outcome).toBeUndefined(); - expect(record.event?.end).toBeUndefined(); - expect(record.event?.duration).toBeUndefined(); - expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); - expect(record.kibana?.server_uuid).toBeUndefined(); - expect(record.kibana?.task).toBeUndefined(); - expect(record.kibana?.version).toBeUndefined(); - expect(record?.ecs).toBeUndefined(); + // these fields should be explicitly set + expect(record.event?.action).toEqual('active-instance'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([ruleDataWithName.type?.producer]); + expect(record.event?.start).toEqual(alert.state.start); + expect(record.event?.end).toEqual(alert.state.end); + expect(record.event?.duration).toEqual(alert.state.duration); + expect(record.message).toEqual( + `.test-rule-type:123: 'my rule' active alert: 'aaabbb' in actionGroup: 'aGroup';` + ); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(ruleDataWithName.type?.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(ruleDataWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual( + ruleContextWithScheduleDelay.executionId + ); + expect(record.kibana?.alert?.maintenance_window_ids).toEqual(alert.maintenanceWindowIds); + expect(record.kibana?.alerting?.instance_id).toEqual(alert.id); + expect(record.kibana?.alerting?.action_group_id).toEqual(alert.group); + expect(record.kibana?.saved_objects).toEqual([ + { + id: ruleContextWithScheduleDelay.savedObjectId, + type: ruleContextWithScheduleDelay.savedObjectType, + type_id: ruleDataWithName.type?.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([ruleContextWithScheduleDelay.spaceId]); + expect(record?.rule?.id).toEqual(ruleDataWithName.id); + expect(record?.rule?.license).toEqual(ruleDataWithName.type?.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(ruleDataWithName.type?.id); + expect(record?.rule?.ruleset).toEqual(ruleDataWithName.type?.producer); + expect(record?.rule?.name).toEqual(ruleDataWithName.name); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.alert?.uuid).toBe(alert.uuid); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); }); -}); -describe('updateEvent', () => { - let event: IEvent; - let expectedEvent: IEvent; - beforeEach(() => { - event = initializeExecuteRecord(contextWithScheduleDelay); - expectedEvent = initializeExecuteRecord(contextWithScheduleDelay); - }); + describe('createActionExecuteRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createActionExecuteRecord( + ruleContextWithScheduleDelay, + ruleDataWithName, + [alertSO], + action + ); - test('throws error if event is null', () => { - expect(() => updateEvent(null as unknown as IEvent, {})).toThrowErrorMatchingInlineSnapshot( - `"Cannot update event because it is not initialized."` - ); + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute-action'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([ruleDataWithName.type?.producer]); + expect(record.message).toEqual( + `alert: test:123: 'my-super-cool-rule' instanceId: '123' scheduled actionGroup: 'aGroup' action: .email:abc` + ); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(ruleDataWithName.type?.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(ruleDataWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual( + ruleContextWithScheduleDelay.executionId + ); + expect(record.kibana?.alerting?.instance_id).toEqual(action.alertId); + expect(record.kibana?.alerting?.action_group_id).toEqual(action.alertGroup); + expect(record.kibana?.saved_objects).toEqual([ + { + id: ruleContextWithScheduleDelay.savedObjectId, + type: ruleContextWithScheduleDelay.savedObjectType, + type_id: ruleDataWithName.type?.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + { + id: action.id, + type: 'action', + type_id: action.typeId, + }, + ]); + expect(record.kibana?.space_ids).toEqual([ruleContextWithScheduleDelay.spaceId]); + expect(record?.rule?.id).toEqual(ruleDataWithName.id); + expect(record?.rule?.license).toEqual(ruleDataWithName.type?.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(ruleDataWithName.type?.id); + expect(record?.rule?.ruleset).toEqual(ruleDataWithName.type?.producer); + expect(record?.rule?.name).toEqual(ruleDataWithName.name); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); }); - test('throws error if event is undefined', () => { - expect(() => - updateEvent(undefined as unknown as IEvent, {}) - ).toThrowErrorMatchingInlineSnapshot(`"Cannot update event because it is not initialized."`); - }); + describe('updateEvent', () => { + let event: IEvent; + let expectedEvent: IEvent; + beforeEach(() => { + event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); + expectedEvent = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); + }); - test('updates event message if provided', () => { - updateEvent(event, { message: 'tell me something good' }); - expect(event).toEqual({ - ...expectedEvent, - message: 'tell me something good', + test('throws error if event is null', () => { + expect(() => updateEvent(null as unknown as IEvent, {})).toThrowErrorMatchingInlineSnapshot( + `"Cannot update event because it is not initialized."` + ); }); - }); - test('updates event outcome if provided', () => { - updateEvent(event, { outcome: 'yay' }); - expect(event).toEqual({ - ...expectedEvent, - event: { - ...expectedEvent?.event, - outcome: 'yay', - }, + test('throws error if event is undefined', () => { + expect(() => + updateEvent(undefined as unknown as IEvent, {}) + ).toThrowErrorMatchingInlineSnapshot(`"Cannot update event because it is not initialized."`); }); - }); - test('updates event error if provided', () => { - updateEvent(event, { error: 'oh no' }); - expect(event).toEqual({ - ...expectedEvent, - error: { - message: 'oh no', - }, + test('updates event message if provided', () => { + updateEvent(event, { message: 'tell me something good' }); + expect(event).toEqual({ + ...expectedEvent, + message: 'tell me something good', + }); }); - }); - test('updates event rule name if provided', () => { - updateEvent(event, { ruleName: 'test rule' }); - expect(event).toEqual({ - ...expectedEvent, - rule: { - ...expectedEvent?.rule, - name: 'test rule', - }, + test('updates event outcome if provided', () => { + updateEvent(event, { outcome: 'yay' }); + expect(event).toEqual({ + ...expectedEvent, + event: { + ...expectedEvent?.event, + outcome: 'yay', + }, + }); }); - }); - test('updates event status if provided', () => { - updateEvent(event, { status: 'ok' }); - expect(event).toEqual({ - ...expectedEvent, - kibana: { - ...expectedEvent?.kibana, - alerting: { - status: 'ok', + test('updates event error if provided', () => { + updateEvent(event, { error: 'oh no' }); + expect(event).toEqual({ + ...expectedEvent, + error: { + message: 'oh no', }, - }, + }); }); - }); - test('updates event reason if provided', () => { - updateEvent(event, { reason: 'my-reason' }); - expect(event).toEqual({ - ...expectedEvent, - event: { - ...expectedEvent?.event, - reason: 'my-reason', - }, + test('updates event status if provided', () => { + updateEvent(event, { status: 'ok' }); + expect(event).toEqual({ + ...expectedEvent, + kibana: { + ...expectedEvent?.kibana, + alerting: { + status: 'ok', + }, + }, + }); + }); + + test('updates event reason if provided', () => { + updateEvent(event, { reason: 'my-reason' }); + expect(event).toEqual({ + ...expectedEvent, + event: { + ...expectedEvent?.event, + reason: 'my-reason', + }, + }); }); - }); - test('updates all fields if provided', () => { - updateEvent(event, { - message: 'tell me something good', - outcome: 'yay', - error: 'oh no', - ruleName: 'test rule', - status: 'ok', - reason: 'my-reason', - }); - expect(event).toEqual({ - ...expectedEvent, - message: 'tell me something good', - kibana: { - ...expectedEvent?.kibana, - alerting: { - status: 'ok', - }, - }, - event: { - ...expectedEvent?.event, + test('updates all fields if provided', () => { + updateEvent(event, { + message: 'tell me something good', outcome: 'yay', + error: 'oh no', + status: 'ok', reason: 'my-reason', - }, - error: { - message: 'oh no', - }, - rule: { - ...expectedEvent?.rule, - name: 'test rule', - }, + }); + expect(event).toEqual({ + ...expectedEvent, + message: 'tell me something good', + kibana: { + ...expectedEvent?.kibana, + alerting: { + status: 'ok', + }, + }, + event: { + ...expectedEvent?.event, + outcome: 'yay', + reason: 'my-reason', + }, + error: { + message: 'oh no', + }, + }); + }); + }); + + describe('updateEventWithRuleData', () => { + let event: IEvent; + let expectedEvent: IEvent; + beforeEach(() => { + event = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); + expectedEvent = initializeExecuteRecord(ruleContextWithScheduleDelay, ruleData, [alertSO]); + }); + + test('throws error if event is null', () => { + expect(() => + updateEventWithRuleData(null as unknown as IEvent, {}) + ).toThrowErrorMatchingInlineSnapshot(`"Cannot update event because it is not initialized."`); + }); + + test('throws error if event is undefined', () => { + expect(() => + updateEventWithRuleData(undefined as unknown as IEvent, {}) + ).toThrowErrorMatchingInlineSnapshot(`"Cannot update event because it is not initialized."`); + }); + + test('updates event rule name if provided', () => { + updateEventWithRuleData(event, { ruleName: 'test rule' }); + expect(event).toEqual({ + ...expectedEvent, + rule: { + ...expectedEvent?.rule, + name: 'test rule', + }, + }); + }); + + test('updates event rule id if provided', () => { + updateEventWithRuleData(event, { ruleId: 'abcdefghijklmnop' }); + expect(event).toEqual({ + ...expectedEvent, + rule: { + ...expectedEvent?.rule, + id: 'abcdefghijklmnop', + }, + }); + }); + + test('updates event rule consumer if provided', () => { + updateEventWithRuleData(event, { consumer: 'not-the-original-consumer' }); + expect(event).toEqual({ + ...expectedEvent, + kibana: { + ...expectedEvent?.kibana, + alert: { + ...expectedEvent?.kibana?.alert, + rule: { + ...expectedEvent?.kibana?.alert?.rule, + consumer: 'not-the-original-consumer', + }, + }, + }, + }); + }); + + test('updates event rule ruleTypeId if provided', () => { + updateEventWithRuleData(event, { + ruleType: { ...ruleType, id: 'not-the-original-rule-type-id' }, + }); + expect(event).toEqual({ + ...expectedEvent, + kibana: { + ...expectedEvent?.kibana, + alert: { + ...expectedEvent?.kibana?.alert, + rule: { + ...expectedEvent?.kibana?.alert?.rule, + rule_type_id: 'not-the-original-rule-type-id', + }, + }, + }, + rule: { + ...expectedEvent?.rule, + category: 'not-the-original-rule-type-id', + }, + }); + }); + + test('updates event rule revision if provided', () => { + updateEventWithRuleData(event, { revision: 500 }); + expect(event).toEqual({ + ...expectedEvent, + kibana: { + ...expectedEvent?.kibana, + alert: { + ...expectedEvent?.kibana?.alert, + rule: { + ...expectedEvent?.kibana?.alert?.rule, + revision: 500, + }, + }, + }, + }); + }); + + test('updates event rule saved object if provided', () => { + updateEventWithRuleData(event, { + savedObjects: [ + { id: 'xyz', relation: 'primary', type: 'alert', typeId: 'test1' }, + { id: '111', type: 'action', namespace: 'custom' }, + { id: 'mmm', type: 'ad_hoc_rule_run_params' }, + ], + }); + expect(event).toEqual({ + ...expectedEvent, + kibana: { + ...expectedEvent?.kibana, + saved_objects: [ + { id: 'xyz', rel: 'primary', type: 'alert', type_id: 'test1' }, + { id: '111', type: 'action', namespace: 'custom' }, + { id: 'mmm', type: 'ad_hoc_rule_run_params' }, + ], + }, + }); + }); + + test('updates all fields if provided', () => { + updateEventWithRuleData(event, { + ruleName: 'test rule', + ruleId: 'abcdefghijklmnop', + consumer: 'not-the-original-consumer', + ruleType: { ...ruleType, id: 'not-the-original-rule-type-id' }, + revision: 500, + savedObjects: [ + { id: 'xyz', relation: 'primary', type: 'alert', typeId: 'test1' }, + { id: '111', type: 'action', namespace: 'custom' }, + { id: 'mmm', type: 'ad_hoc_rule_run_params' }, + ], + }); + expect(event).toEqual({ + ...expectedEvent, + rule: { + ...expectedEvent?.rule, + name: 'test rule', + id: 'abcdefghijklmnop', + category: 'not-the-original-rule-type-id', + }, + kibana: { + ...expectedEvent?.kibana, + alert: { + ...expectedEvent?.kibana?.alert, + rule: { + ...expectedEvent?.kibana?.alert?.rule, + consumer: 'not-the-original-consumer', + revision: 500, + rule_type_id: 'not-the-original-rule-type-id', + }, + }, + saved_objects: [ + { id: 'xyz', rel: 'primary', type: 'alert', type_id: 'test1' }, + { id: '111', type: 'action', namespace: 'custom' }, + { id: 'mmm', type: 'ad_hoc_rule_run_params' }, + ], + }, + }); }); }); }); diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts index 60636e194e194..4b3a8b9d5201a 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts @@ -22,26 +22,47 @@ import { RuleRunMetrics } from '../rule_run_metrics_store'; // 1,000,000 nanoseconds in 1 millisecond const Millis2Nanos = 1000 * 1000; -export interface RuleContextOpts { - ruleId: string; - ruleType: UntypedNormalizedRuleType; - consumer: string; +export interface RuleContext { + id: string; + type: UntypedNormalizedRuleType; + consumer?: string; + name?: string; + revision?: number; +} +export interface ContextOpts { + savedObjectId: string; + savedObjectType: string; namespace?: string; spaceId: string; executionId: string; taskScheduledAt: Date; - ruleName?: string; - ruleRevision?: number; } -type RuleContext = RuleContextOpts & { +export type Context = ContextOpts & { taskScheduleDelay: number; }; +export const executionType = { + STANDARD: 'standard', + BACKFILL: 'backfill', +} as const; +export type ExecutionType = typeof executionType[keyof typeof executionType]; + +interface BackfillOpts { + id: string; + start?: string; + interval?: string; +} + interface DoneOpts { timings?: TaskRunnerTimings; status?: RuleExecutionStatus; metrics?: RuleRunMetrics | null; + backfill?: BackfillOpts; +} + +interface LogTimeoutOpts { + backfill?: BackfillOpts; } interface AlertOpts { @@ -67,11 +88,22 @@ export interface ActionOpts { }; } +export interface SavedObjects { + id: string; + type: string; + namespace?: string; + relation?: string; + typeId?: string; +} + export class AlertingEventLogger { private eventLogger: IEventLogger; private isInitialized = false; private startTime?: Date; - private ruleContext?: RuleContextOpts; + private context?: ContextOpts; + private ruleData?: RuleContext; + private relatedSavedObjects: SavedObjects[] = []; + private executionType: ExecutionType = executionType.STANDARD; // this is the "execute" event that will be updated over the lifecycle of this class private event: IEvent; @@ -85,32 +117,85 @@ export class AlertingEventLogger { return this.event; } - public initialize(context: RuleContextOpts) { - if (this.isInitialized) { + public initialize({ + context, + runDate, + ruleData, + type = executionType.STANDARD, + }: { + context: ContextOpts; + runDate: Date; + type?: ExecutionType; + ruleData?: RuleContext; + }) { + if (this.isInitialized || !context) { throw new Error('AlertingEventLogger already initialized'); } + this.context = context; + this.ruleData = ruleData; + this.executionType = type; + this.startTime = runDate; + + const ctx = { + ...this.context, + taskScheduleDelay: this.startTime.getTime() - this.context.taskScheduledAt.getTime(), + }; + + // Populate the "execute" event based on execution type + switch (type) { + case executionType.BACKFILL: + this.initializeBackfill(ctx); + break; + default: + this.initializeStandard(ctx, ruleData); + } + this.isInitialized = true; - this.ruleContext = context; + this.eventLogger.startTiming(this.event, this.startTime); } - public start(runDate: Date) { - if (!this.isInitialized || !this.ruleContext) { - throw new Error('AlertingEventLogger not initialized'); - } + private initializeBackfill(ctx: Context) { + this.relatedSavedObjects = [ + { + id: ctx.savedObjectId, + type: ctx.savedObjectType, + namespace: ctx.namespace, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ]; - this.startTime = runDate; + // not logging an execute-start event for backfills so just fill in the initial event + this.event = initializeExecuteBackfillRecord(ctx, this.relatedSavedObjects); + } - const context = { - ...this.ruleContext, - taskScheduleDelay: this.startTime.getTime() - this.ruleContext.taskScheduledAt.getTime(), - }; + private initializeStandard(ctx: Context, ruleData?: RuleContext) { + if (!ruleData) { + throw new Error('AlertingEventLogger requires rule data'); + } + + this.relatedSavedObjects = [ + { + id: ctx.savedObjectId, + type: ctx.savedObjectType, + typeId: ruleData.type.id, + namespace: ctx.namespace, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ]; // Initialize the "execute" event - this.event = initializeExecuteRecord(context); - this.eventLogger.startTiming(this.event, this.startTime); + this.event = initializeExecuteRecord(ctx, ruleData, this.relatedSavedObjects); // Create and log "execute-start" event - const executeStartEvent = createExecuteStartRecord(context, this.startTime); + const executeStartEvent = { + ...this.event, + event: { + ...this.event.event, + action: EVENT_LOG_ACTIONS.executeStart, + ...(this.startTime ? { start: this.startTime.toISOString() } : {}), + }, + message: `rule execution start: "${ruleData.id}"`, + }; this.eventLogger.logEvent(executeStartEvent); } @@ -123,13 +208,65 @@ export class AlertingEventLogger { }; } - public setRuleName(ruleName: string) { - if (!this.isInitialized || !this.event || !this.ruleContext) { - throw new Error('AlertingEventLogger not initialized'); + public addOrUpdateRuleData({ + name, + id, + consumer, + type, + revision, + }: { + name?: string; + id?: string; + consumer?: string; + revision?: number; + type?: UntypedNormalizedRuleType; + }) { + if (!this.isInitialized) { + throw new Error(`AlertingEventLogger not initialized`); } + if (!this.ruleData) { + if (!id || !type) throw new Error(`Cannot update rule data before it is initialized`); - this.ruleContext.ruleName = ruleName; - updateEvent(this.event, { ruleName }); + this.ruleData = { + id, + type, + }; + } + + if (name) { + this.ruleData.name = name; + } + + if (consumer) { + this.ruleData.consumer = consumer; + } + + if (revision) { + this.ruleData.revision = revision; + } + + let updatedRelatedSavedObjects = false; + if (id && type) { + // add this to saved objects array if it doesn't already exists + if (!this.relatedSavedObjects.find((so) => so.id === id && so.typeId === type.id)) { + updatedRelatedSavedObjects = true; + this.relatedSavedObjects.push({ + id: id!, + typeId: type?.id, + type: RULE_SAVED_OBJECT_TYPE, + namespace: this.context?.namespace, + }); + } + } + + updateEventWithRuleData(this.event, { + ruleName: name, + ruleId: id, + ruleType: type, + consumer, + revision, + savedObjects: updatedRelatedSavedObjects ? this.relatedSavedObjects : undefined, + }); } public setExecutionSucceeded(message: string) { @@ -161,35 +298,58 @@ export class AlertingEventLogger { }); } - public logTimeout() { - if (!this.isInitialized || !this.ruleContext) { + public logTimeout({ backfill }: LogTimeoutOpts = {}) { + if (!this.isInitialized || !this.context) { throw new Error('AlertingEventLogger not initialized'); } - this.eventLogger.logEvent(createExecuteTimeoutRecord(this.ruleContext)); + if (backfill && this.executionType !== executionType.BACKFILL) { + throw new Error('Cannot set backfill fields for non-backfill event log doc'); + } + + const executeTimeoutEvent = createExecuteTimeoutRecord( + this.context, + this.relatedSavedObjects, + this.executionType, + this.ruleData + ); + + if (backfill) { + updateEvent(executeTimeoutEvent, { backfill }); + } + + this.eventLogger.logEvent(executeTimeoutEvent); } public logAlert(alert: AlertOpts) { - if (!this.isInitialized || !this.ruleContext) { + if (!this.isInitialized || !this.context || !this.ruleData) { throw new Error('AlertingEventLogger not initialized'); } - this.eventLogger.logEvent(createAlertRecord(this.ruleContext, alert)); + this.eventLogger.logEvent( + createAlertRecord(this.context, this.ruleData, this.relatedSavedObjects, alert) + ); } public logAction(action: ActionOpts) { - if (!this.isInitialized || !this.ruleContext) { + if (!this.isInitialized || !this.context || !this.ruleData) { throw new Error('AlertingEventLogger not initialized'); } - this.eventLogger.logEvent(createActionExecuteRecord(this.ruleContext, action)); + this.eventLogger.logEvent( + createActionExecuteRecord(this.context, this.ruleData, this.relatedSavedObjects, action) + ); } - public done({ status, metrics, timings }: DoneOpts) { - if (!this.isInitialized || !this.event || !this.ruleContext) { + public done({ status, metrics, timings, backfill }: DoneOpts) { + if (!this.isInitialized || !this.event || !this.context || !this.ruleData) { throw new Error('AlertingEventLogger not initialized'); } + if (backfill && this.executionType !== executionType.BACKFILL) { + throw new Error('Cannot set backfill fields for non-backfill event log doc'); + } + this.eventLogger.stopTiming(this.event); if (status) { @@ -204,7 +364,7 @@ export class AlertingEventLogger { ...(this.event.message && this.event.event?.outcome === 'failure' ? {} : { - message: `${this.ruleContext.ruleType.id}:${this.ruleContext.ruleId}: execution failed`, + message: `${this.ruleData.type?.id}:${this.context.savedObjectId}: execution failed`, }), }); } else { @@ -226,28 +386,24 @@ export class AlertingEventLogger { updateEvent(this.event, { timings }); } + if (backfill) { + updateEvent(this.event, { backfill }); + } + this.eventLogger.logEvent(this.event); } } -export function createExecuteStartRecord(context: RuleContext, startTime?: Date) { - const event = initializeExecuteRecord(context); - return { - ...event, - event: { - ...event.event, - action: EVENT_LOG_ACTIONS.executeStart, - ...(startTime ? { start: startTime.toISOString() } : {}), - }, - message: `rule execution start: "${context.ruleId}"`, - }; -} - -export function createAlertRecord(context: RuleContextOpts, alert: AlertOpts) { +export function createAlertRecord( + context: ContextOpts, + ruleData: RuleContext, + savedObjects: SavedObjects[], + alert: AlertOpts +) { return createAlertEventLogRecordObject({ - ruleId: context.ruleId, - ruleType: context.ruleType, - consumer: context.consumer, + ruleId: ruleData.id, + ruleType: ruleData.type, + consumer: ruleData.consumer, namespace: context.namespace, spaceId: context.spaceId, executionId: context.executionId, @@ -257,101 +413,111 @@ export function createAlertRecord(context: RuleContextOpts, alert: AlertOpts) { instanceId: alert.id, group: alert.group, message: alert.message, - savedObjects: [ - { - id: context.ruleId, - type: RULE_SAVED_OBJECT_TYPE, - typeId: context.ruleType.id, - relation: SAVED_OBJECT_REL_PRIMARY, - }, - ], - ruleName: context.ruleName, + savedObjects, + ruleName: ruleData.name, flapping: alert.flapping, maintenanceWindowIds: alert.maintenanceWindowIds, - ruleRevision: context.ruleRevision, + ruleRevision: ruleData.revision, }); } -export function createActionExecuteRecord(context: RuleContextOpts, action: ActionOpts) { +export function createActionExecuteRecord( + context: ContextOpts, + ruleData: RuleContext, + savedObjects: SavedObjects[], + action: ActionOpts +) { return createAlertEventLogRecordObject({ - ruleId: context.ruleId, - ruleType: context.ruleType, - consumer: context.consumer, + ruleId: ruleData.id, + ruleType: ruleData.type, + consumer: ruleData.consumer, namespace: context.namespace, spaceId: context.spaceId, executionId: context.executionId, action: EVENT_LOG_ACTIONS.executeAction, instanceId: action.alertId, group: action.alertGroup, - message: `alert: ${context.ruleType.id}:${context.ruleId}: '${context.ruleName}' instanceId: '${action.alertId}' scheduled actionGroup: '${action.alertGroup}' action: ${action.typeId}:${action.id}`, + message: `alert: ${ruleData.type?.id}:${ruleData.id}: '${ruleData.name}' instanceId: '${action.alertId}' scheduled actionGroup: '${action.alertGroup}' action: ${action.typeId}:${action.id}`, savedObjects: [ - { - id: context.ruleId, - type: RULE_SAVED_OBJECT_TYPE, - typeId: context.ruleType.id, - relation: SAVED_OBJECT_REL_PRIMARY, - }, + ...savedObjects, { type: 'action', id: action.id, typeId: action.typeId, }, ], - ruleName: context.ruleName, + ruleName: ruleData.name, alertSummary: action.alertSummary, - ruleRevision: context.ruleRevision, + ruleRevision: ruleData.revision, }); } -export function createExecuteTimeoutRecord(context: RuleContextOpts) { +export function createExecuteTimeoutRecord( + context: ContextOpts, + savedObjects: SavedObjects[], + type: ExecutionType, + ruleData?: RuleContext +) { + let message = ''; + switch (type) { + case executionType.BACKFILL: + message = `backfill "${context.savedObjectId}" cancelled due to timeout`; + break; + default: + message = `rule: ${ruleData?.type?.id}:${context.savedObjectId}: '${ + ruleData?.name ?? '' + }' execution cancelled due to timeout - exceeded rule type timeout of ${ + ruleData?.type?.ruleTaskTimeout + }`; + } return createAlertEventLogRecordObject({ - ruleId: context.ruleId, - ruleType: context.ruleType, - consumer: context.consumer, + ruleId: ruleData?.id, + ruleType: ruleData?.type, + consumer: ruleData?.consumer, namespace: context.namespace, spaceId: context.spaceId, executionId: context.executionId, action: EVENT_LOG_ACTIONS.executeTimeout, - message: `rule: ${context.ruleType.id}:${context.ruleId}: '${ - context.ruleName ?? '' - }' execution cancelled due to timeout - exceeded rule type timeout of ${ - context.ruleType.ruleTaskTimeout - }`, - savedObjects: [ - { - id: context.ruleId, - type: RULE_SAVED_OBJECT_TYPE, - typeId: context.ruleType.id, - relation: SAVED_OBJECT_REL_PRIMARY, - }, - ], - ruleName: context.ruleName, - ruleRevision: context.ruleRevision, + message, + savedObjects, + ruleName: ruleData?.name, + ruleRevision: ruleData?.revision, }); } -export function initializeExecuteRecord(context: RuleContext) { +export function initializeExecuteRecord( + context: Context, + ruleData: RuleContext, + so: SavedObjects[] +) { return createAlertEventLogRecordObject({ - ruleId: context.ruleId, - ruleType: context.ruleType, - consumer: context.consumer, + ruleId: ruleData.id, + ruleType: ruleData.type, + consumer: ruleData.consumer, + ruleRevision: ruleData.revision, namespace: context.namespace, spaceId: context.spaceId, executionId: context.executionId, action: EVENT_LOG_ACTIONS.execute, - ruleRevision: context.ruleRevision, task: { scheduled: context.taskScheduledAt.toISOString(), scheduleDelay: Millis2Nanos * context.taskScheduleDelay, }, - savedObjects: [ - { - id: context.ruleId, - type: RULE_SAVED_OBJECT_TYPE, - typeId: context.ruleType.id, - relation: SAVED_OBJECT_REL_PRIMARY, - }, - ], + savedObjects: so, + }); +} + +export function initializeExecuteBackfillRecord(context: Context, so: SavedObjects[]) { + return createAlertEventLogRecordObject({ + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: EVENT_LOG_ACTIONS.executeBackfill, + task: { + scheduled: context.taskScheduledAt.toISOString(), + scheduleDelay: Millis2Nanos * context.taskScheduleDelay, + }, + savedObjects: so, }); } @@ -360,25 +526,105 @@ interface UpdateEventOpts { outcome?: string; alertingOutcome?: string; error?: string; - ruleName?: string; status?: string; reason?: string; metrics?: RuleRunMetrics; timings?: TaskRunnerTimings; + backfill?: BackfillOpts; maintenanceWindowIds?: string[]; } +interface UpdateRuleOpts { + ruleName?: string; + ruleId?: string; + consumer?: string; + ruleType?: UntypedNormalizedRuleType; + revision?: number; + savedObjects?: SavedObjects[]; +} + +export function updateEventWithRuleData(event: IEvent, opts: UpdateRuleOpts) { + const { ruleName, ruleId, consumer, ruleType, revision, savedObjects } = opts; + if (!event) { + throw new Error('Cannot update event because it is not initialized.'); + } + + if (ruleName) { + event.rule = { + ...event.rule, + name: ruleName, + }; + } + + if (ruleId) { + event.rule = { + ...event.rule, + id: ruleId, + }; + } + + if (consumer) { + event.kibana = event.kibana || {}; + event.kibana.alert = event.kibana.alert || {}; + event.kibana.alert.rule = event.kibana.alert.rule || {}; + event.kibana.alert.rule.consumer = consumer; + } + + if (ruleType) { + event.kibana = event.kibana || {}; + event.kibana.alert = event.kibana.alert || {}; + event.kibana.alert.rule = event.kibana.alert.rule || {}; + if (ruleType.id) { + event.kibana.alert.rule.rule_type_id = ruleType.id; + event.rule = { + ...event.rule, + category: ruleType.id, + }; + } + if (ruleType.minimumLicenseRequired) { + event.rule = { + ...event.rule, + license: ruleType.minimumLicenseRequired, + }; + } + if (ruleType.producer) { + event.rule = { + ...event.rule, + ruleset: ruleType.producer, + }; + } + } + + if (revision) { + event.kibana = event.kibana || {}; + event.kibana.alert = event.kibana.alert || {}; + event.kibana.alert.rule = event.kibana.alert.rule || {}; + event.kibana.alert.rule.revision = revision; + } + + if (savedObjects && savedObjects.length > 0) { + event.kibana = event.kibana || {}; + event.kibana.saved_objects = savedObjects.map((so) => ({ + ...(so.relation ? { rel: so.relation } : {}), + type: so.type, + id: so.id, + type_id: so.typeId, + namespace: so.namespace, + })); + } +} + export function updateEvent(event: IEvent, opts: UpdateEventOpts) { const { message, outcome, error, - ruleName, status, reason, metrics, timings, alertingOutcome, + backfill, maintenanceWindowIds, } = opts; if (!event) { @@ -404,13 +650,6 @@ export function updateEvent(event: IEvent, opts: UpdateEventOpts) { event.error.message = error; } - if (ruleName) { - event.rule = { - ...event.rule, - name: ruleName, - }; - } - if (status) { event.kibana = event.kibana || {}; event.kibana.alerting = event.kibana.alerting || {}; @@ -447,6 +686,14 @@ export function updateEvent(event: IEvent, opts: UpdateEventOpts) { }; } + if (backfill) { + event.kibana = event.kibana || {}; + event.kibana.alert = event.kibana.alert || {}; + event.kibana.alert.rule = event.kibana.alert.rule || {}; + event.kibana.alert.rule.execution = event.kibana.alert.rule.execution || {}; + event.kibana.alert.rule.execution.backfill = backfill; + } + if (timings) { event.kibana = event.kibana || {}; event.kibana.alert = event.kibana.alert || {}; diff --git a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts index 251df68e5267f..8231faa43c74d 100644 --- a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts +++ b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts @@ -13,8 +13,8 @@ export type Event = Exclude; interface CreateAlertEventLogRecordParams { executionId?: string; - ruleId: string; - ruleType: UntypedNormalizedRuleType; + ruleId?: string; + ruleType?: UntypedNormalizedRuleType; action: string; spaceId?: string; consumer?: string; @@ -31,9 +31,9 @@ interface CreateAlertEventLogRecordParams { scheduleDelay?: number; }; savedObjects: Array<{ - type: string; - id: string; - typeId: string; + type?: string; + id?: string; + typeId?: string; relation?: string; }>; flapping?: boolean; @@ -88,7 +88,7 @@ export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecor event: { action, kind: 'alert', - category: [ruleType.producer], + ...(ruleType?.producer ? { category: [ruleType?.producer] } : {}), ...(state?.start ? { start: state.start as string } : {}), ...(state?.end ? { end: state.end as string } : {}), ...(state?.duration !== undefined ? { duration: state.duration as string } : {}), @@ -99,8 +99,8 @@ export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecor ...(maintenanceWindowIds ? { maintenance_window_ids: maintenanceWindowIds } : {}), ...(alertUuid ? { uuid: alertUuid } : {}), rule: { - revision: ruleRevision, - rule_type_id: ruleType.id, + ...(ruleRevision !== undefined ? { revision: ruleRevision } : {}), + ...(ruleType?.id ? { rule_type_id: ruleType.id } : {}), ...(consumer ? { consumer } : {}), ...(executionId ? { @@ -125,9 +125,9 @@ export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecor ...(message ? { message } : {}), rule: { id: ruleId, - license: ruleType.minimumLicenseRequired, - category: ruleType.id, - ruleset: ruleType.producer, + ...(ruleType?.minimumLicenseRequired ? { license: ruleType.minimumLicenseRequired } : {}), + ...(ruleType?.id ? { category: ruleType.id } : {}), + ...(ruleType?.producer ? { ruleset: ruleType.producer } : {}), ...(params.ruleName ? { name: params.ruleName } : {}), }, }; diff --git a/x-pack/plugins/alerting/server/lib/get_time_range.test.ts b/x-pack/plugins/alerting/server/lib/get_time_range.test.ts index 684aea523e3ba..8008323951c2f 100644 --- a/x-pack/plugins/alerting/server/lib/get_time_range.test.ts +++ b/x-pack/plugins/alerting/server/lib/get_time_range.test.ts @@ -24,36 +24,92 @@ describe('getTimeRange', () => { jest.resetAllMocks(); }); - test('returns time range with no query delay', () => { - const { dateStart, dateEnd } = getTimeRange(logger, { delay: 0 }, '5m'); + test(`returns time range with no options`, () => { + const { dateStart, dateEnd } = getTimeRange({ + logger, + }); + expect(dateStart).toBe('2023-10-04T00:00:00.000Z'); + expect(dateEnd).toBe('2023-10-04T00:00:00.000Z'); + expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 0 seconds'); + }); + + test(`returns time range with window`, () => { + const { dateStart, dateEnd } = getTimeRange({ + logger, + window: '5m', + }); expect(dateStart).toBe('2023-10-03T23:55:00.000Z'); expect(dateEnd).toBe('2023-10-04T00:00:00.000Z'); expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 0 seconds'); }); - test('returns time range with a query delay', () => { - const { dateStart, dateEnd } = getTimeRange(logger, { delay: 45 }, '5m'); - expect(dateStart).toBe('2023-10-03T23:54:15.000Z'); - expect(dateEnd).toBe('2023-10-03T23:59:15.000Z'); - expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 45 seconds'); + test(`returns time range with queryDelay`, () => { + const { dateStart, dateEnd } = getTimeRange({ + logger, + queryDelay: 30, + }); + expect(dateStart).toBe('2023-10-03T23:59:30.000Z'); + expect(dateEnd).toBe('2023-10-03T23:59:30.000Z'); + expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 30 seconds'); }); - test('returns time range with no query delay and no time range', () => { - const { dateStart, dateEnd } = getTimeRange(logger, { delay: 0 }); - expect(dateStart).toBe('2023-10-04T00:00:00.000Z'); - expect(dateEnd).toBe('2023-10-04T00:00:00.000Z'); + test(`returns time range with forceNow`, () => { + const { dateStart, dateEnd } = getTimeRange({ + logger, + forceNow: new Date('2022-10-04T00:00:00.000Z'), + }); + expect(dateStart).toBe('2022-10-04T00:00:00.000Z'); + expect(dateEnd).toBe('2022-10-04T00:00:00.000Z'); expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 0 seconds'); }); - test('returns time range with a query delay and no time range', () => { - const { dateStart, dateEnd } = getTimeRange(logger, { delay: 45 }); - expect(dateStart).toBe('2023-10-03T23:59:15.000Z'); - expect(dateEnd).toBe('2023-10-03T23:59:15.000Z'); + test(`returns time range with window and queryDelay`, () => { + const { dateStart, dateEnd } = getTimeRange({ + logger, + window: '5m', + queryDelay: 30, + }); + expect(dateStart).toBe('2023-10-03T23:54:30.000Z'); + expect(dateEnd).toBe('2023-10-03T23:59:30.000Z'); + expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 30 seconds'); + }); + + test(`returns time range with window and forceNow`, () => { + const { dateStart, dateEnd } = getTimeRange({ + logger, + window: '5m', + forceNow: new Date('2022-10-04T00:00:00.000Z'), + }); + expect(dateStart).toBe('2022-10-03T23:55:00.000Z'); + expect(dateEnd).toBe('2022-10-04T00:00:00.000Z'); + expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 0 seconds'); + }); + + test(`returns time range with queryDelay and forceNow`, () => { + const { dateStart, dateEnd } = getTimeRange({ + logger, + forceNow: new Date('2022-10-04T00:00:00.000Z'), + queryDelay: 30, + }); + expect(dateStart).toBe('2022-10-03T23:59:30.000Z'); + expect(dateEnd).toBe('2022-10-03T23:59:30.000Z'); + expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 30 seconds'); + }); + + test(`returns time range with all options specified`, () => { + const { dateStart, dateEnd } = getTimeRange({ + logger, + window: '5m', + queryDelay: 45, + forceNow: new Date('2022-10-04T00:00:00.000Z'), + }); + expect(dateStart).toBe('2022-10-03T23:54:15.000Z'); + expect(dateEnd).toBe('2022-10-03T23:59:15.000Z'); expect(logger.debug).toHaveBeenCalledWith('Adjusting rule query time range by 45 seconds'); }); - test('throws an error when the time window is invalid', () => { - expect(() => getTimeRange(logger, { delay: 45 }, '5k')).toThrowErrorMatchingInlineSnapshot( + test('throws an error when window is invalid', () => { + expect(() => getTimeRange({ logger, window: '5k' })).toThrowErrorMatchingInlineSnapshot( `"Invalid format for windowSize: \\"5k\\""` ); expect(logger.debug).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/alerting/server/lib/get_time_range.ts b/x-pack/plugins/alerting/server/lib/get_time_range.ts index 001b5df614ddd..405d194da8a47 100644 --- a/x-pack/plugins/alerting/server/lib/get_time_range.ts +++ b/x-pack/plugins/alerting/server/lib/get_time_range.ts @@ -7,17 +7,25 @@ import { i18n } from '@kbn/i18n'; import { Logger } from '@kbn/logging'; -import { parseDuration, RulesSettingsQueryDelayProperties } from '../../common'; - -export function getTimeRange( - logger: Logger, - queryDelaySettings: RulesSettingsQueryDelayProperties, - window?: string -) { - let timeWindow: number = 0; +import { parseDuration } from '../../common'; + +export interface GetTimeRangeResult { + dateStart: string; + dateEnd: string; +} + +interface GetTimeRangeOpts { + forceNow?: Date; + logger: Logger; + queryDelay?: number; + window?: string; +} + +const getWindowDurationInMs = (window?: string): number => { + let durationInMs: number = 0; if (window) { try { - timeWindow = parseDuration(window); + durationInMs = parseDuration(window); } catch (err) { throw new Error( i18n.translate('xpack.alerting.invalidWindowSizeErrorMessage', { @@ -29,12 +37,20 @@ export function getTimeRange( ); } } - logger.debug(`Adjusting rule query time range by ${queryDelaySettings.delay} seconds`); - const queryDelay = queryDelaySettings.delay * 1000; - const date = Date.now(); - const dateStart = new Date(date - (timeWindow + queryDelay)).toISOString(); - const dateEnd = new Date(date - queryDelay).toISOString(); + return durationInMs; +}; + +export function getTimeRange({ forceNow, logger, queryDelay, window }: GetTimeRangeOpts) { + const queryDelayS = queryDelay ?? 0; + const queryDelayMs = queryDelayS * 1000; + const timeWindowMs: number = getWindowDurationInMs(window); + const date = forceNow ? forceNow : new Date(); + + logger.debug(`Adjusting rule query time range by ${queryDelayS} seconds`); + + const dateStart = new Date(date.valueOf() - (timeWindowMs + queryDelayMs)).toISOString(); + const dateEnd = new Date(date.valueOf() - queryDelayMs).toISOString(); return { dateStart, dateEnd }; } diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 820670a63e21c..907c85ab27da5 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -77,7 +77,12 @@ import { } from './types'; import { registerAlertingUsageCollector } from './usage'; import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/task'; -import { setupSavedObjects, getLatestRuleVersion, RULE_SAVED_OBJECT_TYPE } from './saved_objects'; +import { + setupSavedObjects, + getLatestRuleVersion, + RULE_SAVED_OBJECT_TYPE, + AD_HOC_RUN_SAVED_OBJECT_TYPE, +} from './saved_objects'; import { initializeApiKeyInvalidator, scheduleApiKeyInvalidatorTask, @@ -103,12 +108,14 @@ import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter import { ConnectorAdapter, ConnectorAdapterParams } from './connector_adapters/types'; import { DataStreamAdapter, getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter'; import { createGetAlertIndicesAliasFn, GetAlertIndicesAlias } from './lib'; +import { BackfillClient } from './backfill_client/backfill_client'; export const EVENT_LOG_PROVIDER = 'alerting'; export const EVENT_LOG_ACTIONS = { execute: 'execute', executeStart: 'execute-start', executeAction: 'execute-action', + executeBackfill: 'execute-backfill', newInstance: 'new-instance', recoveredInstance: 'recovered-instance', activeInstance: 'active-instance', @@ -223,6 +230,7 @@ export class AlertingPlugin { private alertsService: AlertsService | null; private pluginStop$: Subject; private dataStreamAdapter?: DataStreamAdapter; + private backfillClient?: BackfillClient; private nodeRoles: PluginInitializerContext['node']['roles']; private readonly connectorAdapterRegistry = new ConnectorAdapterRegistry(); @@ -276,6 +284,17 @@ export class AlertingPlugin { ); } + const taskManagerStartPromise = core + .getStartServices() + .then(([_, alertingStart]) => alertingStart.taskManager); + + this.backfillClient = new BackfillClient({ + logger: this.logger, + taskManagerSetup: plugins.taskManager, + taskManagerStartPromise, + taskRunnerFactory: this.taskRunnerFactory, + }); + this.eventLogger = plugins.eventLog.getLogger({ event: { provider: EVENT_LOG_PROVIDER }, }); @@ -320,10 +339,7 @@ export class AlertingPlugin { const usageCollection = plugins.usageCollection; if (usageCollection) { - registerAlertingUsageCollector( - usageCollection, - core.getStartServices().then(([_, { taskManager }]) => taskManager) - ); + registerAlertingUsageCollector(usageCollection, taskManagerStartPromise); const eventLogIndex = this.eventLogService.getIndexPattern(); initializeAlertingTelemetry(this.telemetryLogger, core, plugins.taskManager, eventLogIndex); } @@ -479,7 +495,7 @@ export class AlertingPlugin { licenseState?.setNotifyUsage(plugins.licensing.featureUsage.notifyUsage); const encryptedSavedObjectsClient = plugins.encryptedSavedObjects.getClient({ - includedHiddenTypes: [RULE_SAVED_OBJECT_TYPE], + includedHiddenTypes: [RULE_SAVED_OBJECT_TYPE, AD_HOC_RUN_SAVED_OBJECT_TYPE], }); const spaceIdToNamespace = (spaceId?: string) => { @@ -524,6 +540,7 @@ export class AlertingPlugin { maxScheduledPerMinute: this.config.rules.maxScheduledPerMinute, getAlertIndicesAlias: createGetAlertIndicesAliasFn(this.ruleTypeRegistry!), alertsService: this.alertsService, + backfillClient: this.backfillClient!, connectorAdapterRegistry: this.connectorAdapterRegistry, uiSettings: core.uiSettings, }); @@ -576,9 +593,6 @@ export class AlertingPlugin { encryptedSavedObjectsClient, basePathService: core.http.basePath, eventLogger: this.eventLogger!, - internalSavedObjectsRepository: core.savedObjects.createInternalRepository([ - RULE_SAVED_OBJECT_TYPE, - ]), executionContext: core.executionContext, ruleTypeRegistry: this.ruleTypeRegistry!, alertsService: this.alertsService, @@ -591,6 +605,7 @@ export class AlertingPlugin { usageCounter: this.usageCounter, getRulesSettingsClientWithRequest, getMaintenanceWindowClientWithRequest, + backfillClient: this.backfillClient!, connectorAdapterRegistry: this.connectorAdapterRegistry, }); diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/delete/delete_backfill_route.test.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/delete/delete_backfill_route.test.ts new file mode 100644 index 0000000000000..55a8ea2ae22ea --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/delete/delete_backfill_route.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../lib/license_state.mock'; +import { verifyApiAccess } from '../../../../lib/license_api_access'; +import { mockHandlerArguments } from '../../../_mock_handler_arguments'; +import { rulesClientMock } from '../../../../rules_client.mock'; +import { deleteBackfillRoute } from './delete_backfill_route'; + +const rulesClient = rulesClientMock.create(); + +jest.mock('../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +describe('deleteBackfillRoute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should delete the backfill', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + deleteBackfillRoute(router, licenseState); + + rulesClient.deleteBackfill.mockResolvedValueOnce({}); + const [config, handler] = router.delete.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: { id: 'abc' } }); + + expect(config.path).toEqual('/internal/alerting/rules/backfill/{id}'); + + await handler(context, req, res); + + expect(rulesClient.deleteBackfill).toHaveBeenLastCalledWith('abc'); + expect(res.noContent).toHaveBeenCalledTimes(1); + }); + + test('ensures the license allows for deleting backfills', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + deleteBackfillRoute(router, licenseState); + + rulesClient.deleteBackfill.mockResolvedValueOnce({}); + const [, handler] = router.delete.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: { id: 'abc' } }); + await handler(context, req, res); + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + test('ensures the license check prevents for deleting backfills when appropriate', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + deleteBackfillRoute(router, licenseState); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('Failure'); + }); + const [, handler] = router.delete.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: { id: 'abc' } }); + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/delete/delete_backfill_route.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/delete/delete_backfill_route.ts new file mode 100644 index 0000000000000..57e0a2ed2af8d --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/delete/delete_backfill_route.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { IRouter } from '@kbn/core/server'; +import { + deleteParamsSchemaV1, + DeleteBackfillRequestParamsV1, +} from '../../../../../common/routes/backfill/apis/delete'; +import { ILicenseState } from '../../../../lib'; +import { verifyAccessAndContext } from '../../../lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types'; + +export const deleteBackfillRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.delete( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/backfill/{id}`, + validate: { + params: deleteParamsSchemaV1, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = (await context.alerting).getRulesClient(); + const params: DeleteBackfillRequestParamsV1 = req.params; + + await rulesClient.deleteBackfill(params.id); + return res.noContent(); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/find/find_backfill_route.test.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/find/find_backfill_route.test.ts new file mode 100644 index 0000000000000..fb461fa416f9b --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/find/find_backfill_route.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../lib/license_state.mock'; +import { verifyApiAccess } from '../../../../lib/license_api_access'; +import { mockHandlerArguments } from '../../../_mock_handler_arguments'; +import { transformRequestV1, transformResponseV1 } from './transforms'; +import { rulesClientMock } from '../../../../rules_client.mock'; +import { findBackfillRoute } from './find_backfill_route'; +import { FindBackfillResult } from '../../../../application/backfill/methods/find/types'; + +const rulesClient = rulesClientMock.create(); + +jest.mock('../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +describe('findBackfillRoute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const mockFindOptions = { + page: 1, + per_page: 10, + rule_ids: 'abc', + }; + + const mockFindResult: FindBackfillResult = { + page: 0, + perPage: 10, + total: 1, + data: [ + { + id: 'abc', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { interval: '12h' }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + id: '1', + }, + spaceId: 'default', + start: '2023-11-16T08:00:00.000Z', + status: 'pending', + schedule: [{ runAt: '2023-11-16T20:00:00.000Z', interval: '12h', status: 'pending' }], + }, + { + id: 'def', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { interval: '12h' }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + id: '2', + }, + spaceId: 'default', + start: '2023-11-16T08:00:00.000Z', + status: 'pending', + schedule: [ + { runAt: '2023-11-16T20:00:00.000Z', interval: '12h', status: 'pending' }, + { runAt: '2023-11-17T08:00:00.000Z', interval: '12h', status: 'pending' }, + ], + }, + ], + }; + + test('should find backfills with the proper parameters', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + findBackfillRoute(router, licenseState); + + rulesClient.findBackfill.mockResolvedValueOnce(mockFindResult); + const [config, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { query: mockFindOptions }); + + expect(config.path).toEqual('/internal/alerting/rules/backfill/_find'); + + await handler(context, req, res); + + expect(rulesClient.findBackfill).toHaveBeenLastCalledWith(transformRequestV1(mockFindOptions)); + expect(res.ok).toHaveBeenLastCalledWith({ + body: transformResponseV1(mockFindResult), + }); + }); + + test('ensures the license allows for finding backfills', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + findBackfillRoute(router, licenseState); + + rulesClient.findBackfill.mockResolvedValueOnce(mockFindResult); + const [, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { query: mockFindOptions }); + await handler(context, req, res); + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + test('ensures the license check prevents for finding backfills when appropriate', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + findBackfillRoute(router, licenseState); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('Failure'); + }); + const [, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { body: mockFindOptions }); + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/find/find_backfill_route.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/find/find_backfill_route.ts new file mode 100644 index 0000000000000..0e8dcdb6fc28a --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/find/find_backfill_route.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { IRouter } from '@kbn/core/server'; +import { + findQuerySchemaV1, + FindBackfillRequestQueryV1, + FindBackfillResponseV1, +} from '../../../../../common/routes/backfill/apis/find'; +import { ILicenseState } from '../../../../lib'; +import { verifyAccessAndContext } from '../../../lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types'; +import { transformRequestV1, transformResponseV1 } from './transforms'; + +export const findBackfillRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.post( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/backfill/_find`, + validate: { + query: findQuerySchemaV1, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = (await context.alerting).getRulesClient(); + const query: FindBackfillRequestQueryV1 = req.query; + + const result = await rulesClient.findBackfill(transformRequestV1(query)); + const response: FindBackfillResponseV1 = { + body: transformResponseV1(result), + }; + return res.ok(response); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/index.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/index.ts new file mode 100644 index 0000000000000..2eab64276e020 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformRequest } from './transform_request/latest'; +export { transformResponse } from './transform_response/latest'; + +export { transformRequest as transformRequestV1 } from './transform_request/v1'; +export { transformResponse as transformResponseV1 } from './transform_response/v1'; diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_request/latest.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_request/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_request/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_request/v1.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_request/v1.ts new file mode 100644 index 0000000000000..3425f5dea83f4 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_request/v1.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { FindBackfillRequestQueryV1 } from '../../../../../../../common/routes/backfill/apis/find'; +import { FindBackfillParams } from '../../../../../../application/backfill/methods/find/types'; + +export const transformRequest = ({ + end, + page, + per_page, + rule_ids, + start, + sort_field, + sort_order, +}: FindBackfillRequestQueryV1): FindBackfillParams => ({ + end, + page, + perPage: per_page, + ruleIds: rule_ids, + start, + sortField: sort_field, + sortOrder: sort_order, +}); diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_response/latest.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_response/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_response/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_response/v1.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_response/v1.ts new file mode 100644 index 0000000000000..ce959b92d963b --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/find/transforms/transform_response/v1.ts @@ -0,0 +1,22 @@ +/* + * 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 { FindBackfillResult } from '../../../../../../application/backfill/methods/find/types'; +import { FindBackfillResponseBodyV1 } from '../../../../../../../common/routes/backfill/apis/find'; +import { transformBackfillToBackfillResponseV1 } from '../../../../transforms'; + +export const transformResponse = ({ + page, + perPage, + total, + data: backfillData, +}: FindBackfillResult): FindBackfillResponseBodyV1 => ({ + page, + per_page: perPage, + total, + data: backfillData.map(transformBackfillToBackfillResponseV1), +}); diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/get/get_backfill_route.test.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/get/get_backfill_route.test.ts new file mode 100644 index 0000000000000..f24018b1c8deb --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/get/get_backfill_route.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../lib/license_state.mock'; +import { verifyApiAccess } from '../../../../lib/license_api_access'; +import { mockHandlerArguments } from '../../../_mock_handler_arguments'; +import { rulesClientMock } from '../../../../rules_client.mock'; +import { getBackfillRoute } from './get_backfill_route'; +import { Backfill } from '../../../../application/backfill/result/types'; +import { transformBackfillToBackfillResponseV1 } from '../../transforms'; + +const rulesClient = rulesClientMock.create(); + +jest.mock('../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +describe('getBackfillRoute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const mockBackfillResult: Backfill = { + id: 'abc', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { interval: '12h' }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + id: '1', + }, + spaceId: 'default', + start: '2023-11-16T08:00:00.000Z', + status: 'pending', + schedule: [{ runAt: '2023-11-16T20:00:00.000Z', interval: '12h', status: 'pending' }], + }; + + test('should get the backfill', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getBackfillRoute(router, licenseState); + + rulesClient.getBackfill.mockResolvedValueOnce(mockBackfillResult); + const [config, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: { id: 'abc' } }); + + expect(config.path).toEqual('/internal/alerting/rules/backfill/{id}'); + + await handler(context, req, res); + + expect(rulesClient.getBackfill).toHaveBeenLastCalledWith('abc'); + expect(res.ok).toHaveBeenLastCalledWith({ + body: transformBackfillToBackfillResponseV1(mockBackfillResult), + }); + }); + + test('ensures the license allows for getting the backfill', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getBackfillRoute(router, licenseState); + + rulesClient.getBackfill.mockResolvedValueOnce(mockBackfillResult); + const [, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: { id: 'abc' } }); + + await handler(context, req, res); + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + test('ensures the license check prevents for getting the backfill when appropriate', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getBackfillRoute(router, licenseState); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('Failure'); + }); + const [, handler] = router.get.mock.calls[0]; + const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: { id: 'abc' } }); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/get/get_backfill_route.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/get/get_backfill_route.ts new file mode 100644 index 0000000000000..6d84aee4a5f84 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/get/get_backfill_route.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { IRouter } from '@kbn/core/server'; +import { + getParamsSchemaV1, + GetBackfillRequestParamsV1, + GetBackfillResponseV1, +} from '../../../../../common/routes/backfill/apis/get'; +import { ILicenseState } from '../../../../lib'; +import { verifyAccessAndContext } from '../../../lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types'; +import { transformBackfillToBackfillResponseV1 } from '../../transforms'; + +export const getBackfillRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/backfill/{id}`, + validate: { + params: getParamsSchemaV1, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = (await context.alerting).getRulesClient(); + const params: GetBackfillRequestParamsV1 = req.params; + + const result = await rulesClient.getBackfill(params.id); + const response: GetBackfillResponseV1 = { + body: transformBackfillToBackfillResponseV1(result), + }; + return res.ok(response); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.test.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.test.ts new file mode 100644 index 0000000000000..30545a3ac1617 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../../../../lib/license_state.mock'; +import { verifyApiAccess } from '../../../../lib/license_api_access'; +import { mockHandlerArguments } from '../../../_mock_handler_arguments'; +import { transformRequestV1, transformResponseV1 } from './transforms'; +import { rulesClientMock } from '../../../../rules_client.mock'; +import { scheduleBackfillRoute } from './schedule_backfill_route'; +import { ScheduleBackfillResults } from '../../../../application/backfill/methods/schedule/types'; + +const rulesClient = rulesClientMock.create(); + +jest.mock('../../../../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +describe('scheduleBackfillRoute', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const mockScheduleOptions = [ + { + rule_id: 'abc', + start: '2023-11-16T08:00:00.000Z', + end: '2023-11-16T08:20:00.000Z', + }, + ]; + + const mockBackfillResult: ScheduleBackfillResults = [ + { + id: 'abc', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { interval: '12h' }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + id: '1', + }, + spaceId: 'default', + start: '2023-11-16T08:00:00.000Z', + status: 'pending', + schedule: [{ runAt: '2023-11-16T20:00:00.000Z', interval: '12h', status: 'pending' }], + }, + { + id: 'def', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { interval: '12h' }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + id: '2', + }, + spaceId: 'default', + start: '2023-11-16T08:00:00.000Z', + status: 'pending', + schedule: [ + { runAt: '2023-11-16T20:00:00.000Z', interval: '12h', status: 'pending' }, + { runAt: '2023-11-17T08:00:00.000Z', interval: '12h', status: 'pending' }, + ], + }, + ]; + + test('should schedule the backfill', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + scheduleBackfillRoute(router, licenseState); + + rulesClient.scheduleBackfill.mockResolvedValueOnce(mockBackfillResult); + const [config, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { body: mockScheduleOptions } + ); + + expect(config.path).toEqual('/internal/alerting/rules/backfill/_schedule'); + + await handler(context, req, res); + + expect(rulesClient.scheduleBackfill).toHaveBeenLastCalledWith( + transformRequestV1(mockScheduleOptions) + ); + expect(res.ok).toHaveBeenLastCalledWith({ + body: transformResponseV1(mockBackfillResult), + }); + }); + + test('ensures the license allows for scheduling the backfill', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + scheduleBackfillRoute(router, licenseState); + + rulesClient.scheduleBackfill.mockResolvedValueOnce(mockBackfillResult); + const [, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { body: mockScheduleOptions } + ); + await handler(context, req, res); + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + test('ensures the license check prevents for scheduling the backfill when appropriate', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + scheduleBackfillRoute(router, licenseState); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('Failure'); + }); + const [, handler] = router.post.mock.calls[0]; + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { body: mockScheduleOptions } + ); + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.ts new file mode 100644 index 0000000000000..5f7e89d38ce33 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/schedule_backfill_route.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { IRouter } from '@kbn/core/server'; +import { + scheduleBodySchemaV1, + ScheduleBackfillRequestBodyV1, + ScheduleBackfillResponseV1, +} from '../../../../../common/routes/backfill/apis/schedule'; +import { ILicenseState } from '../../../../lib'; +import { verifyAccessAndContext } from '../../../lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../../../types'; +import { transformRequestV1, transformResponseV1 } from './transforms'; + +export const scheduleBackfillRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.post( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/backfill/_schedule`, + validate: { + body: scheduleBodySchemaV1, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = (await context.alerting).getRulesClient(); + const body: ScheduleBackfillRequestBodyV1 = req.body; + + const result = await rulesClient.scheduleBackfill(transformRequestV1(body)); + const response: ScheduleBackfillResponseV1 = { + body: transformResponseV1(result), + }; + return res.ok(response); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/index.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/index.ts new file mode 100644 index 0000000000000..2eab64276e020 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformRequest } from './transform_request/latest'; +export { transformResponse } from './transform_response/latest'; + +export { transformRequest as transformRequestV1 } from './transform_request/v1'; +export { transformResponse as transformResponseV1 } from './transform_response/v1'; diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/latest.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/v1.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/v1.ts new file mode 100644 index 0000000000000..170d85c4f862b --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_request/v1.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { ScheduleBackfillRequestBodyV1 } from '../../../../../../../common/routes/backfill/apis/schedule'; +import { ScheduleBackfillParams } from '../../../../../../application/backfill/methods/schedule/types'; + +export const transformRequest = (request: ScheduleBackfillRequestBodyV1): ScheduleBackfillParams => + request.map(({ rule_id, start, end }) => ({ ruleId: rule_id, start, end })); diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_response/latest.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_response/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_response/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_response/v1.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_response/v1.ts new file mode 100644 index 0000000000000..697169277e75f --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_response/v1.ts @@ -0,0 +1,27 @@ +/* + * 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 { Backfill } from '../../../../../../application/backfill/result/types'; +import type { + ScheduleBackfillError, + ScheduleBackfillResult, + ScheduleBackfillResults, +} from '../../../../../../application/backfill/methods/schedule/types'; +import { ScheduleBackfillResponseBodyV1 } from '../../../../../../../common/routes/backfill/apis/schedule'; +import { transformBackfillToBackfillResponseV1 } from '../../../../transforms'; + +export const transformResponse = ( + results: ScheduleBackfillResults +): ScheduleBackfillResponseBodyV1 => { + return results.map((result: ScheduleBackfillResult) => { + if ((result as ScheduleBackfillError)?.error) { + return result as ScheduleBackfillError; + } + + return transformBackfillToBackfillResponseV1(result as Backfill); + }); +}; diff --git a/x-pack/plugins/alerting/server/routes/backfill/transforms/index.ts b/x-pack/plugins/alerting/server/routes/backfill/transforms/index.ts new file mode 100644 index 0000000000000..64e4938b36979 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/transforms/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformBackfillToBackfillResponse } from './transform_backfill_to_backfill_response/latest'; +export { transformBackfillToBackfillResponse as transformBackfillToBackfillResponseV1 } from './transform_backfill_to_backfill_response/v1'; diff --git a/x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/latest.ts b/x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/latest.ts new file mode 100644 index 0000000000000..cc4284826acba --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformBackfillToBackfillResponse } from './v1'; diff --git a/x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.test.ts b/x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.test.ts new file mode 100644 index 0000000000000..88582bea15b92 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { Backfill } from '../../../../application/backfill/result/types'; +import { transformBackfillToBackfillResponse } from './v1'; + +describe('transformBackfillToBackfillResponse', () => { + const mockBackfillResult: Backfill = { + id: 'abc', + createdAt: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + name: 'my rule name', + tags: ['foo'], + alertTypeId: 'myType', + params: {}, + apiKeyOwner: 'user', + apiKeyCreatedByUser: false, + consumer: 'myApp', + enabled: true, + schedule: { interval: '12h' }, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2019-02-12T21:01:22.479Z', + updatedAt: '2019-02-12T21:01:22.479Z', + revision: 0, + id: '1', + }, + spaceId: 'default', + start: '2023-11-16T08:00:00.000Z', + status: 'pending', + schedule: [{ runAt: '2023-11-16T20:00:00.000Z', interval: '12h', status: 'pending' }], + }; + + describe('transformBackfillToBackfillResponse', () => { + it('transforms backfill correctly', () => { + const result = transformBackfillToBackfillResponse(mockBackfillResult); + expect(result).toEqual({ + id: 'abc', + created_at: '2024-01-30T00:00:00.000Z', + duration: '12h', + enabled: true, + rule: { + name: 'my rule name', + tags: ['foo'], + rule_type_id: 'myType', + params: {}, + api_key_owner: 'user', + api_key_created_by_user: false, + consumer: 'myApp', + enabled: true, + schedule: { interval: '12h' }, + created_by: 'user', + updated_by: 'user', + created_at: '2019-02-12T21:01:22.479Z', + updated_at: '2019-02-12T21:01:22.479Z', + revision: 0, + id: '1', + }, + space_id: 'default', + start: '2023-11-16T08:00:00.000Z', + status: 'pending', + schedule: [{ run_at: '2023-11-16T20:00:00.000Z', interval: '12h', status: 'pending' }], + }); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.ts b/x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.ts new file mode 100644 index 0000000000000..c1c0a3aa53cd1 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/transforms/transform_backfill_to_backfill_response/v1.ts @@ -0,0 +1,43 @@ +/* + * 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 { Backfill } from '../../../../application/backfill/result/types'; + +export const transformBackfillToBackfillResponse = (backfill: Backfill) => { + const { createdAt, rule, spaceId, schedule, ...rest } = backfill; + const { + alertTypeId, + apiKeyOwner, + apiKeyCreatedByUser, + createdBy, + createdAt: ruleCreatedAt, + updatedBy, + updatedAt, + ...restRule + } = rule; + + return { + ...rest, + created_at: createdAt, + space_id: spaceId, + rule: { + ...restRule, + rule_type_id: alertTypeId, + api_key_owner: apiKeyOwner, + api_key_created_by_user: apiKeyCreatedByUser, + created_by: createdBy, + created_at: ruleCreatedAt, + updated_by: updatedBy, + updated_at: updatedAt, + }, + schedule: schedule.map(({ runAt, status, interval }) => ({ + run_at: runAt, + status, + interval, + })), + }; +}; diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index a2d25899fd88e..4261b8f9c9776 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -66,6 +66,12 @@ import { registerAlertsValueSuggestionsRoute } from './suggestions/values_sugges import { getQueryDelaySettingsRoute } from './rules_settings/apis/get/get_query_delay_settings'; import { updateQueryDelaySettingsRoute } from './rules_settings/apis/update/update_query_delay_settings'; +// backfill API +import { scheduleBackfillRoute } from './backfill/apis/schedule/schedule_backfill_route'; +import { getBackfillRoute } from './backfill/apis/get/get_backfill_route'; +import { findBackfillRoute } from './backfill/apis/find/find_backfill_route'; +import { deleteBackfillRoute } from './backfill/apis/delete/delete_backfill_route'; + export interface RouteOptions { router: IRouter; licenseState: ILicenseState; @@ -139,4 +145,10 @@ export function defineRoutes(opts: RouteOptions) { bulkUntrackAlertsByQueryRoute(router, licenseState); getQueryDelaySettingsRoute(router, licenseState); updateQueryDelaySettingsRoute(router, licenseState); + + // backfill APIs + scheduleBackfillRoute(router, licenseState); + getBackfillRoute(router, licenseState); + findBackfillRoute(router, licenseState); + deleteBackfillRoute(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 0b4122e221ca5..eedd46eaa71ec 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -41,6 +41,10 @@ const createRulesClientMock = () => { getGlobalExecutionLogWithAuth: jest.fn(), getActionErrorLog: jest.fn(), getActionErrorLogWithAuth: jest.fn(), + scheduleBackfill: jest.fn(), + getBackfill: jest.fn(), + findBackfill: jest.fn(), + deleteBackfill: jest.fn(), getSpaceId: jest.fn(), bulkEdit: jest.fn(), bulkDeleteRules: jest.fn(), diff --git a/x-pack/plugins/alerting/server/rules_client/common/audit_events.test.ts b/x-pack/plugins/alerting/server/rules_client/common/audit_events.test.ts index 6d42be630ffd1..1ec0831505507 100644 --- a/x-pack/plugins/alerting/server/rules_client/common/audit_events.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/common/audit_events.test.ts @@ -5,8 +5,13 @@ * 2.0. */ -import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; -import { RuleAuditAction, ruleAuditEvent } from './audit_events'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { + RuleAuditAction, + ruleAuditEvent, + AdHocRunAuditAction, + adHocRunAuditEvent, +} from './audit_events'; describe('#ruleAuditEvent', () => { test('creates event with `unknown` outcome', () => { @@ -104,3 +109,100 @@ describe('#ruleAuditEvent', () => { `); }); }); + +describe('#adHocRunAuditEvent', () => { + test('creates event with `unknown` outcome', () => { + expect( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.GET, + outcome: 'unknown', + savedObject: { type: AD_HOC_RUN_SAVED_OBJECT_TYPE, id: 'AD_HOC_RUN_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "ad_hoc_run_get", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "AD_HOC_RUN_ID", + "type": "ad_hoc_run_params", + }, + }, + "message": "User is getting ad hoc run for ad_hoc_run_params [id=AD_HOC_RUN_ID]", + } + `); + }); + + test('creates event with `success` outcome', () => { + expect( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.FIND, + savedObject: { type: AD_HOC_RUN_SAVED_OBJECT_TYPE, id: 'AD_HOC_RUN_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "ad_hoc_run_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "AD_HOC_RUN_ID", + "type": "ad_hoc_run_params", + }, + }, + "message": "User has found ad hoc run for ad_hoc_run_params [id=AD_HOC_RUN_ID]", + } + `); + }); + + test('creates event with `failure` outcome', () => { + expect( + adHocRunAuditEvent({ + action: AdHocRunAuditAction.DELETE, + savedObject: { type: AD_HOC_RUN_SAVED_OBJECT_TYPE, id: 'AD_HOC_RUN_ID' }, + error: new Error('ERROR_MESSAGE'), + }) + ).toMatchInlineSnapshot(` + Object { + "error": Object { + "code": "Error", + "message": "ERROR_MESSAGE", + }, + "event": Object { + "action": "ad_hoc_run_delete", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "deletion", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "AD_HOC_RUN_ID", + "type": "ad_hoc_run_params", + }, + }, + "message": "Failed attempt to delete ad hoc run for ad_hoc_run_params [id=AD_HOC_RUN_ID]", + } + `); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_client/common/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/common/audit_events.ts index 0088f623f43b7..1ab77379cbeaf 100644 --- a/x-pack/plugins/alerting/server/rules_client/common/audit_events.ts +++ b/x-pack/plugins/alerting/server/rules_client/common/audit_events.ts @@ -8,6 +8,7 @@ import { EcsEvent } from '@kbn/core/server'; import { AuditEvent } from '@kbn/security-plugin/server'; import { ArrayElement } from '@kbn/utility-types'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../saved_objects'; export enum RuleAuditAction { CREATE = 'rule_create', @@ -34,11 +35,19 @@ export enum RuleAuditAction { UNSNOOZE = 'rule_unsnooze', RUN_SOON = 'rule_run_soon', UNTRACK_ALERT = 'rule_alert_untrack', + SCHEDULE_BACKFILL = 'rule_schedule_backfill', +} + +export enum AdHocRunAuditAction { + CREATE = 'ad_hoc_run_create', + GET = 'ad_hoc_run_get', + FIND = 'ad_hoc_run_find', + DELETE = 'ad_hoc_run_delete', } type VerbsTuple = [string, string, string]; -const eventVerbs: Record = { +const ruleEventVerbs: Record = { rule_create: ['create', 'creating', 'created'], rule_get: ['access', 'accessing', 'accessed'], rule_resolve: ['access', 'accessing', 'accessed'], @@ -83,9 +92,21 @@ const eventVerbs: Record = { 'accessed global execution KPI for', ], rule_alert_untrack: ['untrack', 'untracking', 'untracked'], + rule_schedule_backfill: [ + 'schedule backfill for', + 'scheduling backfill for', + 'scheduled backfill for', + ], +}; + +const adHocRunEventVerbs: Record = { + ad_hoc_run_create: ['create ad hoc run for', 'creating ad hoc run for', 'created ad hoc run for'], + ad_hoc_run_get: ['get ad hoc run for', 'getting ad hoc run for', 'got ad hoc run for'], + ad_hoc_run_find: ['find ad hoc run for', 'finding ad hoc run for', 'found ad hoc run for'], + ad_hoc_run_delete: ['delete ad hoc run for', 'deleting ad hoc run for', 'deleted ad hoc run for'], }; -const eventTypes: Record> = { +const ruleEventTypes: Record> = { rule_create: 'creation', rule_get: 'access', rule_resolve: 'access', @@ -110,6 +131,14 @@ const eventTypes: Record> = { rule_get_execution_kpi: 'access', rule_get_global_execution_kpi: 'access', rule_alert_untrack: 'change', + rule_schedule_backfill: 'access', +}; + +const adHocRunEventTypes: Record> = { + ad_hoc_run_create: 'creation', + ad_hoc_run_get: 'access', + ad_hoc_run_find: 'access', + ad_hoc_run_delete: 'deletion', }; export interface RuleAuditEventParams { @@ -119,6 +148,13 @@ export interface RuleAuditEventParams { error?: Error; } +export interface AdHocRunAuditEventParams { + action: AdHocRunAuditAction; + outcome?: EcsEvent['outcome']; + savedObject?: NonNullable['saved_object']; + error?: Error; +} + export function ruleAuditEvent({ action, savedObject, @@ -126,13 +162,48 @@ export function ruleAuditEvent({ error, }: RuleAuditEventParams): AuditEvent { const doc = savedObject ? `rule [id=${savedObject.id}]` : 'a rule'; - const [present, progressive, past] = eventVerbs[action]; + const [present, progressive, past] = ruleEventVerbs[action]; + const message = error + ? `Failed attempt to ${present} ${doc}` + : outcome === 'unknown' + ? `User is ${progressive} ${doc}` + : `User has ${past} ${doc}`; + const type = ruleEventTypes[action]; + + return { + message, + event: { + action, + category: ['database'], + type: type ? [type] : undefined, + outcome: outcome ?? (error ? 'failure' : 'success'), + }, + kibana: { + saved_object: savedObject, + }, + error: error && { + code: error.name, + message: error.message, + }, + }; +} + +export function adHocRunAuditEvent({ + action, + savedObject, + outcome, + error, +}: AdHocRunAuditEventParams): AuditEvent { + const doc = savedObject + ? `${AD_HOC_RUN_SAVED_OBJECT_TYPE} [id=${savedObject.id}]` + : 'an ad hoc run'; + const [present, progressive, past] = adHocRunEventVerbs[action]; const message = error ? `Failed attempt to ${present} ${doc}` : outcome === 'unknown' ? `User is ${progressive} ${doc}` : `User has ${past} ${doc}`; - const type = eventTypes[action]; + const type = adHocRunEventTypes[action]; return { message, diff --git a/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts index 871debb9b4e9c..5a969e7e8c836 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/add_generated_action_values.test.ts @@ -21,6 +21,7 @@ import { AlertingAuthorization } from '../../authorization'; import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; import { ConstructorOptions } from '../rules_client'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; jest.mock('uuid', () => ({ @@ -62,6 +63,7 @@ describe('addGeneratedActionValues()', () => { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), isSystemAction: jest.fn(), diff --git a/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts index 93b43545b9747..d9800cb35a68e 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/create_new_api_key_set.test.ts @@ -22,6 +22,7 @@ import { RawRule } from '../../types'; import { getBeforeSetup, mockedDateString } from '../tests/lib'; import { createNewAPIKeySet } from './create_new_api_key_set'; import { RulesClientContext } from '../types'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; const taskManager = taskManagerMock.createStart(); @@ -57,6 +58,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; 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 1e1e57c429f07..942d4c4411091 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -69,6 +69,12 @@ import { bulkUntrackAlerts, BulkUntrackBody, } from '../application/rule/methods/bulk_untrack/bulk_untrack_alerts'; +import { ScheduleBackfillParams } from '../application/backfill/methods/schedule/types'; +import { scheduleBackfill } from '../application/backfill/methods/schedule'; +import { getBackfill } from '../application/backfill/methods/get'; +import { findBackfill } from '../application/backfill/methods/find'; +import { deleteBackfill } from '../application/backfill/methods/delete'; +import { FindBackfillParams } from '../application/backfill/methods/find/types'; export type ConstructorOptions = Omit< RulesClientContext, @@ -182,6 +188,15 @@ export class RulesClient { public listRuleTypes = () => listRuleTypes(this.context); + public scheduleBackfill = (params: ScheduleBackfillParams) => + scheduleBackfill(this.context, params); + + public getBackfill = (id: string) => getBackfill(this.context, id); + + public findBackfill = (params: FindBackfillParams) => findBackfill(this.context, params); + + public deleteBackfill = (id: string) => deleteBackfill(this.context, id); + public getSpaceId(): string | undefined { return this.context.spaceId; } diff --git a/x-pack/plugins/alerting/server/rules_client/tests/bulk_enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/bulk_enable.test.ts index 64a243403c406..de465316ec14f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/bulk_enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/bulk_enable.test.ts @@ -46,6 +46,7 @@ import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { migrateLegacyActions } from '../lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -98,6 +99,7 @@ const rulesClientParams: jest.Mocked = { isSystemAction: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts index a5b691894b77c..99f895a9866b2 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/clear_expired_snoozes.test.ts @@ -28,6 +28,7 @@ import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { RuleSnooze } from '../../types'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), @@ -75,6 +76,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/clone.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/clone.test.ts index a23d9b159d79b..9676d19ebf2f0 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/clone.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/clone.test.ts @@ -24,6 +24,7 @@ import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; import { getBeforeSetup } from './lib'; import { RuleDomain } from '../../application/rule/types'; import { ConstructorOptions, RulesClient } from '../rules_client'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; describe('clone', () => { const taskManager = taskManagerMock.createStart(); @@ -34,6 +35,7 @@ describe('clone', () => { const actionsAuthorization = actionsAuthorizationMock.create(); const auditLogger = auditLoggerMock.create(); const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const backfillClient = backfillClientMock.create(); const kibanaVersion = 'v8.2.0'; const createAPIKeyMock = jest.fn(); @@ -63,6 +65,7 @@ describe('clone', () => { getAuthenticationAPIKey: getAuthenticationApiKeyMock, connectorAdapterRegistry: new ConnectorAdapterRegistry(), isSystemAction: jest.fn(), + backfillClient, getAlertIndicesAlias: jest.fn(), alertsService: null, uiSettings: uiSettingsServiceMock.createStartContract(), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts index 20ea58cad66ab..ca62ca9f5ad7f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts @@ -27,6 +27,7 @@ import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_key import { migrateLegacyActions } from '../lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -77,6 +78,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts index 9d21fa3477fb9..35e24eb9af440 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts @@ -28,6 +28,7 @@ import { migrateLegacyActions } from '../lib'; import { migrateLegacyActionsMock } from '../lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -79,6 +80,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index dd48bbe5412c7..695f1185ceffc 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -26,7 +26,8 @@ import { getBeforeSetup, setGlobalDate } from './lib'; import { migrateLegacyActions } from '../lib'; import { migrateLegacyActionsMock } from '../lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; -import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { API_KEY_PENDING_INVALIDATION_TYPE, RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; jest.mock('../lib/siem_legacy_actions/migrate_legacy_actions', () => { return { @@ -76,6 +77,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; @@ -237,7 +239,9 @@ describe('enable()', () => { namespace: 'default', } ); - expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith( + API_KEY_PENDING_INVALIDATION_TYPE + ); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( RULE_SAVED_OBJECT_TYPE, '1', @@ -297,7 +301,9 @@ describe('enable()', () => { namespace: 'default', } ); - expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith( + API_KEY_PENDING_INVALIDATION_TYPE + ); expect(rulesClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: myType/name'); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( RULE_SAVED_OBJECT_TYPE, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts index 70e9ef57b2e6a..660bf24332414 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/find.test.ts @@ -29,6 +29,7 @@ import { enabledRule1, enabledRule2, siemRule1, siemRule2 } from './test_helpers import { formatLegacyActions } from '../lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; jest.mock('../lib/siem_legacy_actions/format_legacy_actions', () => { return { @@ -69,6 +70,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn().mockImplementation((id) => id === 'system_action-id'), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts index 1f136a0c181b3..ac3a8d78a7595 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get.test.ts @@ -26,6 +26,7 @@ import { RecoveredActionGroup } from '../../../common'; import { formatLegacyActions } from '../lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; jest.mock('../lib/siem_legacy_actions/format_legacy_actions', () => { return { @@ -66,6 +67,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts index 0cf207500fdb7..8df9883247683 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_action_error_log.test.ts @@ -28,6 +28,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -65,6 +66,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts index 94db9bd1c9629..20d04cae35e97 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_state.test.ts @@ -23,6 +23,7 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { getBeforeSetup } from './lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; const taskManager = taskManagerMock.createStart(); @@ -58,6 +59,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts index 60769bc20cd6d..b5815b6022cb9 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts @@ -28,6 +28,7 @@ import { RawRule } from '../../types'; import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -63,6 +64,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts index f90f5835dbcd4..6bc7076ab3329 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -29,6 +29,7 @@ import { getExecutionLogAggregation } from '../../lib/get_execution_log_aggregat import { fromKueryExpression } from '@kbn/es-query'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -66,6 +67,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts index e6f3978987c53..b096ec1c75f7d 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/list_rule_types.test.ts @@ -25,6 +25,7 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { getBeforeSetup } from './lib'; import { RecoveredActionGroup } from '../../../common'; import { RegistryRuleType } from '../../rule_type_registry'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; const taskManager = taskManagerMock.createStart(); @@ -60,6 +61,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts index 7911735644403..c84c860599fe2 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts @@ -23,6 +23,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -57,6 +58,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts index 36bba7e734748..1ae6f38a400d4 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_instance.test.ts @@ -23,6 +23,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -57,6 +58,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts index bab353225b28b..b69878fc5ae99 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/resolve.test.ts @@ -26,6 +26,7 @@ import { RecoveredActionGroup } from '../../../common'; import { formatLegacyActions } from '../lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; jest.mock('../lib/siem_legacy_actions/format_legacy_actions', () => { return { @@ -66,6 +67,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts index 653e7dd807c8a..026ba3450a700 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/run_soon.test.ts @@ -24,6 +24,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -59,6 +60,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts index 20eae2a147dda..fd09c74d43899 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts @@ -23,6 +23,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -57,6 +58,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts index 8275e0a88d8ba..948b9f8622002 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_instance.test.ts @@ -23,6 +23,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -57,6 +58,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts index 66881241021ef..0f72a1e20d1d5 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts @@ -24,6 +24,7 @@ import { getBeforeSetup, setGlobalDate } from './lib'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), @@ -64,6 +65,7 @@ const rulesClientParams: jest.Mocked = { connectorAdapterRegistry: new ConnectorAdapterRegistry(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), isSystemAction: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/types.ts b/x-pack/plugins/alerting/server/rules_client/types.ts index 555843d1e38c4..f83d55ca2bb8b 100644 --- a/x-pack/plugins/alerting/server/rules_client/types.ts +++ b/x-pack/plugins/alerting/server/rules_client/types.ts @@ -38,6 +38,7 @@ import { AlertingRulesConfig } from '../config'; import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; import { GetAlertIndicesAlias } from '../lib'; import { AlertsService } from '../alerts_service'; +import { BackfillClient } from '../backfill_client/backfill_client'; export type { BulkEditOperation, @@ -82,6 +83,7 @@ export interface RulesClientContext { readonly connectorAdapterRegistry: ConnectorAdapterRegistry; readonly getAlertIndicesAlias: GetAlertIndicesAlias; readonly alertsService: AlertsService | null; + readonly backfillClient: BackfillClient; readonly isSystemAction: (actionId: string) => boolean; readonly uiSettings: UiSettingsServiceStart; } diff --git a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts index e8d92335f8ee4..dd96f5a51905c 100644 --- a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts @@ -27,6 +27,7 @@ import { TaskStatus } from '@kbn/task-manager-plugin/server/task'; import { RecoveredActionGroup } from '../common'; import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from './saved_objects'; +import { backfillClientMock } from './backfill_client/backfill_client.mock'; jest.mock('./application/rule/methods/get_schedule_frequency', () => ({ validateScheduleLimit: jest.fn(), @@ -71,6 +72,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), connectorAdapterRegistry: new ConnectorAdapterRegistry(), isSystemAction: jest.fn(), uiSettings: uiSettingsServiceMock.createStartContract(), diff --git a/x-pack/plugins/alerting/server/rules_client_factory.test.ts b/x-pack/plugins/alerting/server/rules_client_factory.test.ts index adb6e0c88d30f..9be3e17e6371f 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.test.ts @@ -27,8 +27,13 @@ import { AlertingAuthorization } from './authorization'; import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import { mockRouter } from '@kbn/core-http-router-server-mocks'; +import { + AD_HOC_RUN_SAVED_OBJECT_TYPE, + API_KEY_PENDING_INVALIDATION_TYPE, + RULE_SAVED_OBJECT_TYPE, +} from './saved_objects'; +import { backfillClientMock } from './backfill_client/backfill_client.mock'; import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter_registry'; -import { RULE_SAVED_OBJECT_TYPE } from './saved_objects'; jest.mock('./rules_client'); jest.mock('./authorization/alerting_authorization'); @@ -42,6 +47,7 @@ const securityPluginStart = securityMock.createStart(); const alertingAuthorization = alertingAuthorizationMock.create(); const alertingAuthorizationClientFactory = alertingAuthorizationClientFactoryMock.createFactory(); const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const backfillClient = backfillClientMock.create(); const rulesClientFactoryParams: jest.Mocked = { logger: loggingSystemMock.create().get(), @@ -58,6 +64,7 @@ const rulesClientFactoryParams: jest.Mocked = { kibanaVersion: '7.10.0', authorization: alertingAuthorizationClientFactory as unknown as AlertingAuthorizationClientFactory, + backfillClient, connectorAdapterRegistry: new ConnectorAdapterRegistry(), uiSettings: uiSettingsServiceMock.createStartContract(), getAlertIndicesAlias: jest.fn(), @@ -92,7 +99,11 @@ test('creates a rules client with proper constructor arguments when security is expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { excludedExtensions: [SECURITY_EXTENSION_ID], - includedHiddenTypes: [RULE_SAVED_OBJECT_TYPE, 'api_key_pending_invalidation'], + includedHiddenTypes: [ + RULE_SAVED_OBJECT_TYPE, + API_KEY_PENDING_INVALIDATION_TYPE, + AD_HOC_RUN_SAVED_OBJECT_TYPE, + ], }); expect(alertingAuthorizationClientFactory.create).toHaveBeenCalledWith(request); @@ -125,6 +136,7 @@ test('creates a rules client with proper constructor arguments when security is isSystemAction: expect.any(Function), getAlertIndicesAlias: expect.any(Function), alertsService: null, + backfillClient, uiSettings: rulesClientFactoryParams.uiSettings, }); }); @@ -143,7 +155,11 @@ test('creates a rules client with proper constructor arguments', async () => { expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { excludedExtensions: [SECURITY_EXTENSION_ID], - includedHiddenTypes: [RULE_SAVED_OBJECT_TYPE, 'api_key_pending_invalidation'], + includedHiddenTypes: [ + RULE_SAVED_OBJECT_TYPE, + API_KEY_PENDING_INVALIDATION_TYPE, + AD_HOC_RUN_SAVED_OBJECT_TYPE, + ], }); expect(alertingAuthorizationClientFactory.create).toHaveBeenCalledWith(request); @@ -172,6 +188,7 @@ test('creates a rules client with proper constructor arguments', async () => { isSystemAction: expect.any(Function), getAlertIndicesAlias: expect.any(Function), alertsService: null, + backfillClient, uiSettings: rulesClientFactoryParams.uiSettings, }); }); diff --git a/x-pack/plugins/alerting/server/rules_client_factory.ts b/x-pack/plugins/alerting/server/rules_client_factory.ts index ad3035791bde6..50a11dd178c38 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.ts @@ -29,8 +29,13 @@ import { AlertingAuthorizationClientFactory } from './alerting_authorization_cli import { AlertingRulesConfig } from './config'; import { GetAlertIndicesAlias } from './lib'; import { AlertsService } from './alerts_service/alerts_service'; +import { BackfillClient } from './backfill_client/backfill_client'; +import { + AD_HOC_RUN_SAVED_OBJECT_TYPE, + API_KEY_PENDING_INVALIDATION_TYPE, + RULE_SAVED_OBJECT_TYPE, +} from './saved_objects'; import { ConnectorAdapterRegistry } from './connector_adapters/connector_adapter_registry'; -import { RULE_SAVED_OBJECT_TYPE } from './saved_objects'; export interface RulesClientFactoryOpts { logger: Logger; taskManager: TaskManagerStartContract; @@ -50,6 +55,7 @@ export interface RulesClientFactoryOpts { maxScheduledPerMinute: AlertingRulesConfig['maxScheduledPerMinute']; getAlertIndicesAlias: GetAlertIndicesAlias; alertsService: AlertsService | null; + backfillClient: BackfillClient; connectorAdapterRegistry: ConnectorAdapterRegistry; uiSettings: CoreStart['uiSettings']; } @@ -74,6 +80,7 @@ export class RulesClientFactory { private maxScheduledPerMinute!: AlertingRulesConfig['maxScheduledPerMinute']; private getAlertIndicesAlias!: GetAlertIndicesAlias; private alertsService!: AlertsService | null; + private backfillClient!: BackfillClient; private connectorAdapterRegistry!: ConnectorAdapterRegistry; private uiSettings!: CoreStart['uiSettings']; @@ -100,6 +107,7 @@ export class RulesClientFactory { this.maxScheduledPerMinute = options.maxScheduledPerMinute; this.getAlertIndicesAlias = options.getAlertIndicesAlias; this.alertsService = options.alertsService; + this.backfillClient = options.backfillClient; this.connectorAdapterRegistry = options.connectorAdapterRegistry; this.uiSettings = options.uiSettings; } @@ -122,7 +130,11 @@ export class RulesClientFactory { maxScheduledPerMinute: this.maxScheduledPerMinute, unsecuredSavedObjectsClient: savedObjects.getScopedClient(request, { excludedExtensions: [SECURITY_EXTENSION_ID], - includedHiddenTypes: [RULE_SAVED_OBJECT_TYPE, 'api_key_pending_invalidation'], + includedHiddenTypes: [ + RULE_SAVED_OBJECT_TYPE, + API_KEY_PENDING_INVALIDATION_TYPE, + AD_HOC_RUN_SAVED_OBJECT_TYPE, + ], }), authorization: this.authorization.create(request), actionsAuthorization: actions.getActionsAuthorizationWithRequest(request), @@ -132,6 +144,7 @@ export class RulesClientFactory { auditLogger: securityPluginSetup?.audit.asScoped(request), getAlertIndicesAlias: this.getAlertIndicesAlias, alertsService: this.alertsService, + backfillClient: this.backfillClient, connectorAdapterRegistry: this.connectorAdapterRegistry, uiSettings: this.uiSettings, diff --git a/x-pack/plugins/alerting/server/saved_objects/ad_hoc_run_params_model_versions.ts b/x-pack/plugins/alerting/server/saved_objects/ad_hoc_run_params_model_versions.ts new file mode 100644 index 0000000000000..10d8dc759e9be --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/ad_hoc_run_params_model_versions.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SavedObjectsModelVersion, + SavedObjectsModelVersionMap, +} from '@kbn/core-saved-objects-server'; +import { AdHocRunSO } from '../data/ad_hoc_run/types'; +import { rawAdHocRunParamsSchemaV1 } from './schemas/raw_ad_hoc_run_params'; + +interface CustomSavedObjectsModelVersion extends SavedObjectsModelVersion { + isCompatibleWithPreviousVersion: (param: AdHocRunSO) => boolean; +} + +export interface CustomSavedObjectsModelVersionMap extends SavedObjectsModelVersionMap { + [modelVersion: string]: CustomSavedObjectsModelVersion; +} + +export const adHocRunParamsModelVersions: CustomSavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: rawAdHocRunParamsSchemaV1.extends({}, { unknowns: 'ignore' }), + create: rawAdHocRunParamsSchemaV1, + }, + isCompatibleWithPreviousVersion: () => true, + }, +}; + +export const getLatestAdHocRunParamsVersion = () => + Math.max(...Object.keys(adHocRunParamsModelVersions).map(Number)); + +export function getMinimumCompatibleVersion( + modelVersions: CustomSavedObjectsModelVersionMap, + version: number, + adHocRunParam: AdHocRunSO +): number { + if (version === 1) { + return 1; + } + + if (modelVersions[version].isCompatibleWithPreviousVersion(adHocRunParam)) { + return getMinimumCompatibleVersion(modelVersions, version - 1, adHocRunParam); + } + + return version; +} diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 6adbc23f31081..0dd261c4c39f1 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -30,8 +30,11 @@ import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, } from '../../common'; import { ruleModelVersions } from './rule_model_versions'; +import { adHocRunParamsModelVersions } from './ad_hoc_run_params_model_versions'; export const RULE_SAVED_OBJECT_TYPE = 'alert'; +export const AD_HOC_RUN_SAVED_OBJECT_TYPE = 'ad_hoc_run_params'; +export const API_KEY_PENDING_INVALIDATION_TYPE = 'api_key_pending_invalidation'; export const RuleAttributesToEncrypt = ['apiKey']; @@ -85,6 +88,10 @@ export type RuleAttributesNotPartiallyUpdatable = | 'meta' | 'alertDelay'; +export const AdHocRunAttributesToEncrypt = ['apiKeyToUse']; +export const AdHocRunAttributesIncludedInAAD = ['rule', 'spaceId']; +export type AdHocRunAttributesNotPartiallyUpdatable = 'rule' | 'spaceId' | 'apiKeyToUse'; + export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, @@ -126,7 +133,7 @@ export function setupSavedObjects( }); savedObjects.registerType({ - name: 'api_key_pending_invalidation', + name: API_KEY_PENDING_INVALIDATION_TYPE, indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, hidden: true, namespaceType: 'agnostic', @@ -158,6 +165,48 @@ export function setupSavedObjects( mappings: maintenanceWindowMappings, }); + savedObjects.registerType({ + name: AD_HOC_RUN_SAVED_OBJECT_TYPE, + indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, + hidden: true, + namespaceType: 'multiple-isolated', + mappings: { + dynamic: false, + properties: { + apiKeyId: { + type: 'keyword', + }, + createdAt: { + type: 'date', + }, + end: { + type: 'date', + }, + rule: { + properties: { + alertTypeId: { + type: 'keyword', + }, + consumer: { + type: 'keyword', + }, + }, + }, + start: { + type: 'date', + }, + // TODO to allow searching/filtering by status + // status: { + // type: 'keyword' + // } + }, + }, + management: { + importableAndExportable: false, + }, + modelVersions: adHocRunParamsModelVersions, + }); + // Encrypted attributes encryptedSavedObjects.registerType({ type: RULE_SAVED_OBJECT_TYPE, @@ -167,8 +216,15 @@ export function setupSavedObjects( // Encrypted attributes encryptedSavedObjects.registerType({ - type: 'api_key_pending_invalidation', + type: API_KEY_PENDING_INVALIDATION_TYPE, attributesToEncrypt: new Set(['apiKeyId']), attributesToIncludeInAAD: new Set(['createdAt']), }); + + // Encrypted attributes + encryptedSavedObjects.registerType({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributesToEncrypt: new Set(AdHocRunAttributesToEncrypt), + attributesToIncludeInAAD: new Set(AdHocRunAttributesIncludedInAAD), + }); } diff --git a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_ad_hoc_run_params/index.ts b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_ad_hoc_run_params/index.ts new file mode 100644 index 0000000000000..977a13f3a7e4b --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_ad_hoc_run_params/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { rawAdHocRunParamsSchema as rawAdHocRunParamsSchemaV1 } from './v1'; diff --git a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_ad_hoc_run_params/v1.ts b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_ad_hoc_run_params/v1.ts new file mode 100644 index 0000000000000..8676c2c606912 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_ad_hoc_run_params/v1.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +const rawAdHocRunStatus = schema.oneOf([ + schema.literal('complete'), + schema.literal('pending'), + schema.literal('running'), + schema.literal('error'), + schema.literal('timeout'), +]); + +const rawAdHocRunSchedule = schema.object({ + interval: schema.string(), + status: rawAdHocRunStatus, + runAt: schema.string(), +}); + +const rawAdHocRunParamsRuleSchema = schema.object({ + name: schema.string(), + tags: schema.arrayOf(schema.string()), + alertTypeId: schema.string(), + params: schema.recordOf(schema.string(), schema.maybe(schema.any())), + apiKeyOwner: schema.nullable(schema.string()), + apiKeyCreatedByUser: schema.maybe(schema.nullable(schema.boolean())), + consumer: schema.string(), + enabled: schema.boolean(), + schedule: schema.object({ + interval: schema.string(), + }), + createdBy: schema.nullable(schema.string()), + updatedBy: schema.nullable(schema.string()), + updatedAt: schema.string(), + createdAt: schema.string(), + revision: schema.number(), +}); + +export const rawAdHocRunParamsSchema = schema.object({ + apiKeyId: schema.string(), + apiKeyToUse: schema.string(), + createdAt: schema.string(), + duration: schema.string(), + enabled: schema.boolean(), + end: schema.maybe(schema.string()), + rule: rawAdHocRunParamsRuleSchema, + spaceId: schema.string(), + start: schema.string(), + status: rawAdHocRunStatus, + schedule: schema.arrayOf(rawAdHocRunSchedule), +}); diff --git a/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_runner.test.ts new file mode 100644 index 0000000000000..f532fdf04eed9 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_runner.test.ts @@ -0,0 +1,1518 @@ +/* + * 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 sinon from 'sinon'; +import { PluginStartContract as ActionsPluginStart } from '@kbn/actions-plugin/server'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { SavedObject } from '@kbn/core/server'; +import { + elasticsearchServiceMock, + executionContextServiceMock, + httpServiceMock, + loggingSystemMock, + savedObjectsRepositoryMock, + savedObjectsServiceMock, + uiSettingsServiceMock, +} from '@kbn/core/server/mocks'; +import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { IEventLogger } from '@kbn/event-log-plugin/server'; +import { eventLoggerMock } from '@kbn/event-log-plugin/server/mocks'; +import { SharePluginStart } from '@kbn/share-plugin/server'; +import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server'; +import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; +import { AdHocTaskRunner } from './ad_hoc_task_runner'; +import { TaskRunnerContext } from './types'; +import { backfillClientMock } from '../backfill_client/backfill_client.mock'; +import { maintenanceWindowClientMock } from '../maintenance_window_client.mock'; +import { rulesClientMock } from '../rules_client.mock'; +import { rulesSettingsClientMock } from '../rules_settings_client.mock'; +import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; +import { + AlertingEventLogger, + executionType, + ContextOpts, +} from '../lib/alerting_event_logger/alerting_event_logger'; +import { AdHocRunSchedule, AdHocRunSO } from '../data/ad_hoc_run/types'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { adHocRunStatus } from '../../common/constants'; +import { DATE_1970, ruleType } from './fixtures'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; +import { alertsMock } from '../mocks'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; +import { AlertsService } from '../alerts_service'; +import { ReplaySubject } from 'rxjs'; +import { getDataStreamAdapter } from '../alerts_service/lib/data_stream_adapter'; +import { + AlertInstanceContext, + AlertInstanceState, + DEFAULT_FLAPPING_SETTINGS, + RuleAlertData, + RuleExecutorOptions, + RuleTypeParams, + RuleTypeState, +} from '../types'; +import { + TIMESTAMP, + EVENT_ACTION, + EVENT_KIND, + ALERT_ACTION_GROUP, + ALERT_DURATION, + ALERT_FLAPPING, + ALERT_FLAPPING_HISTORY, + ALERT_INSTANCE_ID, + ALERT_MAINTENANCE_WINDOW_IDS, + ALERT_RULE_CATEGORY, + ALERT_RULE_CONSUMER, + ALERT_RULE_EXECUTION_UUID, + ALERT_RULE_NAME, + ALERT_RULE_PARAMETERS, + ALERT_RULE_PRODUCER, + ALERT_RULE_REVISION, + ALERT_RULE_TYPE_ID, + ALERT_RULE_TAGS, + ALERT_RULE_UUID, + ALERT_START, + ALERT_STATUS, + ALERT_TIME_RANGE, + ALERT_UUID, + ALERT_WORKFLOW_STATUS, + SPACE_IDS, + TAGS, + VERSION, + ALERT_CONSECUTIVE_MATCHES, + ALERT_RULE_EXECUTION_TIMESTAMP, +} from '@kbn/rule-data-utils'; +import { validateRuleTypeParams } from '../lib/validate_rule_type_params'; +import { ruleRunMetricsStoreMock } from '../lib/rule_run_metrics_store.mock'; +import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; + +const UUID = '5f6aa57d-3e22-484e-bae8-cbed868f4d28'; + +jest.mock('uuid', () => ({ + v4: () => UUID, +})); +jest.mock('../lib/wrap_scoped_cluster_client', () => ({ + createWrappedScopedClusterClientFactory: jest.fn(), +})); +jest.mock('../lib/alerting_event_logger/alerting_event_logger'); +jest.mock('../lib/rule_run_metrics_store'); +jest.mock('../lib/validate_rule_type_params'); +const mockValidateRuleTypeParams = validateRuleTypeParams as jest.MockedFunction< + typeof validateRuleTypeParams +>; + +const useDataStreamForAlerts = true; +const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); +const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); +const logger: ReturnType = loggingSystemMock.createLogger(); + +let fakeTimer: sinon.SinonFakeTimers; +type TaskRunnerFactoryInitializerParamsType = jest.Mocked & { + actionsPlugin: jest.Mocked; + eventLogger: jest.Mocked; + executionContext: ReturnType; +}; +const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + +const alertingEventLogger = alertingEventLoggerMock.create(); +const alertsService = new AlertsService({ + logger, + pluginStop$: new ReplaySubject(1), + kibanaVersion: '8.8.0', + elasticsearchClientPromise: Promise.resolve(clusterClient), + dataStreamAdapter: getDataStreamAdapter({ useDataStreamForAlerts }), +}); +const backfillClient = backfillClientMock.create(); +const dataPlugin = dataPluginMock.createStartContract(); +const dataViewsMock = { + dataViewsServiceFactory: jest.fn().mockResolvedValue(dataViewPluginMocks.createStartContract()), + getScriptedFieldsEnabled: jest.fn().mockReturnValue(true), +} as DataViewsServerPluginStart; +const elasticsearchService = elasticsearchServiceMock.createInternalStart(); +const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); +const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); +const maintenanceWindowClient = maintenanceWindowClientMock.create(); +const rulesClient = rulesClientMock.create(); +const ruleRunMetricsStore = ruleRunMetricsStoreMock.create(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); +const services = alertsMock.createRuleExecutorServices(); +const uiSettingsService = uiSettingsServiceMock.createStartContract(); + +const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = { + actionsConfigMap: { + default: { + max: 10000, + }, + }, + actionsPlugin: actionsMock.createStart(), + alertsService, + backfillClient, + basePathService: httpServiceMock.createBasePath(), + cancelAlertsOnRuleTimeout: true, + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + data: dataPlugin, + dataViews: dataViewsMock, + elasticsearch: elasticsearchService, + encryptedSavedObjectsClient, + eventLogger: eventLoggerMock.create(), + executionContext: executionContextServiceMock.createInternalStartContract(), + getMaintenanceWindowClientWithRequest: jest.fn().mockReturnValue(maintenanceWindowClient), + getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), + getRulesSettingsClientWithRequest: jest.fn().mockReturnValue(rulesSettingsClientMock.create()), + kibanaBaseUrl: 'https://localhost:5601', + logger, + maxAlerts: 1000, + maxEphemeralActionsPerRule: 10, + ruleTypeRegistry, + savedObjects: savedObjectsService, + share: {} as SharePluginStart, + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + supportsEphemeralTasks: false, + uiSettings: uiSettingsService, + usageCounter: mockUsageCounter, +}; + +const mockedTaskInstance: ConcreteTaskInstance = { + id: '', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: {}, + taskType: 'backfill', + timeoutOverride: '3m', + params: { + adHocRunParamsId: 'abc', + spaceId: 'default', + }, + ownerId: null, +}; +const ruleTypeWithAlerts: jest.Mocked = { + ...ruleType, + alerts: { + context: 'test', + mappings: { + fieldMap: { + textField: { + type: 'keyword', + required: false, + }, + numericField: { + type: 'long', + required: false, + }, + }, + }, + shouldWrite: true, + }, +}; + +const RULE_ID = 'rule-id'; + +describe('Ad Hoc Task Runner', () => { + let mockedAdHocRunSO: SavedObject; + let schedule1: AdHocRunSchedule; + let schedule2: AdHocRunSchedule; + let schedule3: AdHocRunSchedule; + let schedule4: AdHocRunSchedule; + let schedule5: AdHocRunSchedule; + let alertingEventLoggerInitializer: ContextOpts; + + beforeAll(() => { + fakeTimer = sinon.useFakeTimers(); + alertingEventLoggerInitializer = { + executionId: UUID, + savedObjectId: 'abc', + savedObjectType: AD_HOC_RUN_SAVED_OBJECT_TYPE, + spaceId: 'default', + taskScheduledAt: mockedTaskInstance.scheduledAt, + }; + }); + + beforeEach(() => { + schedule1 = { + runAt: '2024-03-01T01:00:00.000Z', + status: adHocRunStatus.PENDING, + interval: '1h', + }; + schedule2 = { + runAt: '2024-03-01T02:00:00.000Z', + status: adHocRunStatus.PENDING, + interval: '1h', + }; + schedule3 = { + runAt: '2024-03-01T03:00:00.000Z', + status: adHocRunStatus.PENDING, + interval: '1h', + }; + schedule4 = { + runAt: '2024-03-01T04:00:00.000Z', + status: adHocRunStatus.PENDING, + interval: '1h', + }; + schedule5 = { + runAt: '2024-03-01T05:00:00.000Z', + status: adHocRunStatus.PENDING, + interval: '1h', + }; + + mockedAdHocRunSO = { + id: 'abc', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + references: [{ type: RULE_SAVED_OBJECT_TYPE, name: 'rule', id: RULE_ID }], + attributes: { + apiKeyId: 'apiKeyId', + apiKeyToUse: 'MTIzOmFiYw==', + createdAt: '2024-03-13T16:24:32.296Z', + duration: '1h', + enabled: true, + end: '2024-03-01T05:00:00.000Z', + rule: { + name: 'test', + tags: [], + alertTypeId: 'siem.queryRule', + // @ts-expect-error + params: { + author: [], + description: 'test', + falsePositives: [], + from: 'now-86460s', + ruleId: '31c54f10-9d3b-45a8-b064-b92e8c6fcbe7', + immutable: false, + license: '', + outputIndex: '', + meta: { + from: '1m', + kibana_siem_app_url: 'https://localhost:5601/app/security', + }, + maxSignals: 100, + riskScore: 21, + riskScoreMapping: [], + severity: 'low', + severityMapping: [], + threat: [], + to: 'now', + references: [], + version: 1, + exceptionsList: [], + relatedIntegrations: [], + requiredFields: [], + setup: '', + type: 'query', + language: 'kuery', + index: ['.kibana-event-log*'], + query: 'event.provider:*', + filters: [], + }, + apiKeyOwner: 'elastic', + apiKeyCreatedByUser: false, + consumer: 'siem', + enabled: true, + schedule: { + interval: '1h', + }, + revision: 0, + createdBy: 'elastic', + createdAt: '2024-03-13T16:06:20.089Z', + updatedBy: 'elastic', + updatedAt: '2024-03-13T16:06:20.089Z', + }, + spaceId: 'default', + start: '2024-03-01T00:00:00.000Z', + status: adHocRunStatus.PENDING, + schedule: [schedule1, schedule2, schedule3, schedule4, schedule5], + }, + }; + jest.resetAllMocks(); + jest + .requireMock('../lib/wrap_scoped_cluster_client') + .createWrappedScopedClusterClientFactory.mockReturnValue({ + client: () => services.scopedClusterClient, + getMetrics: () => ({ + numSearches: 3, + esSearchDurationMs: 33, + totalSearchDurationMs: 23423, + }), + }); + jest + .spyOn(alertsService, 'getContextInitializationPromise') + .mockResolvedValue({ result: true }); + elasticsearchService.client.asScoped.mockReturnValue(services.scopedClusterClient); + alertingEventLogger.getStartAndDuration.mockImplementation(() => ({ start: new Date() })); + (AlertingEventLogger as jest.Mock).mockImplementation(() => alertingEventLogger); + ruleRunMetricsStore.getMetrics.mockReturnValue({ + numSearches: 3, + totalSearchDurationMs: 23423, + esSearchDurationMs: 33, + numberOfTriggeredActions: 0, + numberOfGeneratedActions: 0, + numberOfActiveAlerts: 0, + numberOfRecoveredAlerts: 0, + numberOfNewAlerts: 0, + hasReachedAlertLimit: false, + triggeredActionsStatus: 'complete', + }); + (RuleRunMetricsStore as jest.Mock).mockImplementation(() => ruleRunMetricsStore); + logger.get.mockImplementation(() => logger); + taskRunnerFactoryInitializerParams.executionContext.withContext.mockImplementation((ctx, fn) => + fn() + ); + ruleTypeRegistry.get.mockReturnValue(ruleTypeWithAlerts); + ruleTypeWithAlerts.executor.mockResolvedValue({ state: {} }); + mockValidateRuleTypeParams.mockReturnValue(mockedAdHocRunSO.attributes.rule.params); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(mockedAdHocRunSO); + }); + + afterAll(() => fakeTimer.restore()); + + test('successfully executes the task', async () => { + ruleTypeWithAlerts.executor.mockImplementation( + async ({ + services: executorServices, + }: RuleExecutorOptions< + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + string, + RuleAlertData + >) => { + executorServices.alertsClient?.report({ + id: '1', + actionGroup: 'default', + payload: { textField: 'foo', numericField: 27 }, + }); + return { state: {} }; + } + ); + + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + + const runnerResult = await taskRunner.run(); + expect(runnerResult).toEqual({ state: {}, runAt: new Date('1970-01-01T00:00:00.000Z') }); + await taskRunner.cleanup(); + + // Verify all the expected calls were made before calling the rule executor + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + {} + ); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenCalledWith('siem.queryRule'); + expect(mockValidateRuleTypeParams).toHaveBeenCalledWith( + mockedAdHocRunSO.attributes.rule.params, + ruleTypeWithAlerts.validate.params + ); + // @ts-ignore - accessing private variable + // should run the first entry in the schedule + expect(taskRunner.scheduleToRunIndex).toEqual(0); + + // Verify all the expected calls were made while calling the rule executor + expect(RuleRunMetricsStore).toHaveBeenCalledTimes(1); + expect(ruleTypeWithAlerts.executor).toHaveBeenCalledTimes(1); + const call = ruleTypeWithAlerts.executor.mock.calls[0][0]; + + expect(call.executionId).toEqual(UUID); + expect(call.services).toBeTruthy(); + expect(call.services.alertsClient).not.toBe(null); + expect(call.services.alertsClient?.report).toBeTruthy(); + expect(call.services.alertsClient?.setAlertData).toBeTruthy(); + expect(call.services.scopedClusterClient).toBeTruthy(); + expect(call.params).toEqual(mockedAdHocRunSO.attributes.rule.params); + expect(call.state).toEqual({}); + expect(call.startedAt).toStrictEqual(new Date(schedule1.runAt)); + expect(call.previousStartedAt).toStrictEqual(null); + expect(call.spaceId).toEqual('default'); + expect(call.rule).not.toBe(null); + expect(call.rule.id).toBe(RULE_ID); + expect(call.rule.name).toBe('test'); + expect(call.rule.tags).toEqual([]); + expect(call.rule.consumer).toBe('siem'); + expect(call.rule.enabled).toBe(true); + expect(call.rule.schedule).toEqual({ interval: '1h' }); + expect(call.rule.createdBy).toBe('elastic'); + expect(call.rule.updatedBy).toBe('elastic'); + expect(call.rule.createdAt).toStrictEqual(new Date('2024-03-13T16:06:20.089Z')); + expect(call.rule.updatedAt).toStrictEqual(new Date('2024-03-13T16:06:20.089Z')); + expect(call.rule.notifyWhen).toBe(null); + expect(call.rule.throttle).toBe(null); + expect(call.rule.producer).toBe('alerts'); + expect(call.rule.ruleTypeId).toBe('siem.queryRule'); + expect(call.rule.ruleTypeName).toBe('My test rule'); + expect(call.rule.actions).toEqual([]); + expect(call.flappingSettings).toEqual(DEFAULT_FLAPPING_SETTINGS); + expect(call.maintenanceWindowIds).toBe(undefined); + + expect(clusterClient.bulk).toHaveBeenCalledWith({ + index: '.alerts-test.alerts-default', + refresh: true, + require_alias: !useDataStreamForAlerts, + body: [ + { + create: { + _id: UUID, + ...(useDataStreamForAlerts ? {} : { require_alias: true }), + }, + }, + // new alert doc + { + [TIMESTAMP]: schedule1.runAt, + numericField: 27, + textField: 'foo', + [EVENT_ACTION]: 'open', + [EVENT_KIND]: 'signal', + [ALERT_ACTION_GROUP]: 'default', + [ALERT_DURATION]: 0, + [ALERT_FLAPPING]: false, + [ALERT_FLAPPING_HISTORY]: [true], + [ALERT_INSTANCE_ID]: '1', + [ALERT_MAINTENANCE_WINDOW_IDS]: [], + [ALERT_CONSECUTIVE_MATCHES]: 1, + [ALERT_RULE_CATEGORY]: 'My test rule', + [ALERT_RULE_CONSUMER]: mockedAdHocRunSO.attributes.rule.consumer, + [ALERT_RULE_EXECUTION_UUID]: UUID, + [ALERT_RULE_EXECUTION_TIMESTAMP]: DATE_1970, + [ALERT_RULE_NAME]: mockedAdHocRunSO.attributes.rule.name, + [ALERT_RULE_PARAMETERS]: mockedAdHocRunSO.attributes.rule.params, + [ALERT_RULE_PRODUCER]: 'alerts', + [ALERT_RULE_REVISION]: 0, + [ALERT_RULE_TYPE_ID]: ruleTypeWithAlerts.id, + [ALERT_RULE_TAGS]: mockedAdHocRunSO.attributes.rule.tags, + [ALERT_RULE_UUID]: RULE_ID, + [ALERT_START]: schedule1.runAt, + [ALERT_STATUS]: 'active', + [ALERT_TIME_RANGE]: { gte: schedule1.runAt }, + [ALERT_UUID]: UUID, + [ALERT_WORKFLOW_STATUS]: 'open', + [SPACE_IDS]: ['default'], + [VERSION]: '8.8.0', + [TAGS]: mockedAdHocRunSO.attributes.rule.tags, + }, + ], + }); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { + schedule: [ + { ...schedule1, status: adHocRunStatus.COMPLETE }, + schedule2, + schedule3, + schedule4, + schedule5, + ], + }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + + testAlertingEventLogCalls({ + status: 'ok', + backfillRunAt: schedule1.runAt, + backfillInterval: schedule1.interval, + logAlert: 2, + }); + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).nthCalledWith( + 1, + `Executing ad hoc run for rule test:rule-id for runAt ${schedule1.runAt}` + ); + expect(logger.debug).nthCalledWith( + 2, + `rule test:rule-id: 'test' has 1 active alerts: [{"instanceId":"1","actionGroup":"default"}]` + ); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test('should run with the next pending schedule', async () => { + ruleTypeWithAlerts.executor.mockImplementation( + async ({ + services: executorServices, + }: RuleExecutorOptions< + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + string, + RuleAlertData + >) => { + executorServices.alertsClient?.report({ + id: '1', + actionGroup: 'default', + payload: { textField: 'foo', numericField: 27 }, + }); + return { state: {} }; + } + ); + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + ...mockedAdHocRunSO, + attributes: { + ...mockedAdHocRunSO.attributes, + schedule: [ + { ...schedule1, status: adHocRunStatus.COMPLETE }, + { ...schedule2, status: adHocRunStatus.TIMEOUT }, + { ...schedule3, status: adHocRunStatus.ERROR }, + schedule4, + schedule5, + ], + }, + }); + + const runnerResult = await taskRunner.run(); + // should return a runAt because there's another entry in the schedule + expect(runnerResult).toEqual({ + state: {}, + runAt: new Date('1970-01-01T00:00:00.000Z'), + }); + await taskRunner.cleanup(); + + // Verify all the expected calls were made before calling the rule executor + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalled(); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenCalledWith('siem.queryRule'); + expect(mockValidateRuleTypeParams).toHaveBeenCalled(); + // @ts-ignore - accessing private variable + // should run the first PENDING entry in the schedule + expect(taskRunner.scheduleToRunIndex).toEqual(3); + + // Verify all the expected calls were made while calling the rule executor + expect(RuleRunMetricsStore).toHaveBeenCalledTimes(1); + expect(ruleTypeWithAlerts.executor).toHaveBeenCalledTimes(1); + const call = ruleTypeWithAlerts.executor.mock.calls[0][0]; + + expect(call.startedAt).toStrictEqual(new Date(schedule4.runAt)); + + expect(clusterClient.bulk).toHaveBeenCalledTimes(1); + const bulkCall = clusterClient.bulk.mock.calls[0][0]; + + // @ts-ignore + expect(bulkCall.body[1][TIMESTAMP]).toEqual(schedule4.runAt); + // @ts-ignore + expect(bulkCall.body[1][ALERT_START]).toEqual(schedule4.runAt); + // @ts-ignore + expect(bulkCall.body[1][ALERT_TIME_RANGE]).toEqual({ gte: schedule4.runAt }); + // @ts-ignore + expect(bulkCall.body[1][ALERT_RULE_EXECUTION_TIMESTAMP]).toEqual(DATE_1970); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { + schedule: [ + { ...schedule1, status: adHocRunStatus.COMPLETE }, + { ...schedule2, status: adHocRunStatus.TIMEOUT }, + { ...schedule3, status: adHocRunStatus.ERROR }, + { ...schedule4, status: adHocRunStatus.COMPLETE }, + schedule5, + ], + }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + + testAlertingEventLogCalls({ + status: 'ok', + backfillRunAt: schedule4.runAt, + backfillInterval: schedule4.interval, + logAlert: 2, + }); + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).nthCalledWith( + 1, + `Executing ad hoc run for rule test:rule-id for runAt ${schedule4.runAt}` + ); + expect(logger.debug).nthCalledWith( + 2, + `rule test:rule-id: 'test' has 1 active alerts: [{"instanceId":"1","actionGroup":"default"}]` + ); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test('should delete ad hoc run SO and not return a new runAt date when all schedules have been processed ', async () => { + ruleTypeWithAlerts.executor.mockImplementation( + async ({ + services: executorServices, + }: RuleExecutorOptions< + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + string, + RuleAlertData + >) => { + executorServices.alertsClient?.report({ + id: '1', + actionGroup: 'default', + payload: { textField: 'foo', numericField: 27 }, + }); + return { state: {} }; + } + ); + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + ...mockedAdHocRunSO, + attributes: { + ...mockedAdHocRunSO.attributes, + schedule: [ + { ...schedule1, status: adHocRunStatus.COMPLETE }, + { ...schedule2, status: adHocRunStatus.TIMEOUT }, + { ...schedule3, status: adHocRunStatus.ERROR }, + { ...schedule4, status: adHocRunStatus.COMPLETE }, + schedule5, + ], + }, + }); + + const runnerResult = await taskRunner.run(); + // should not return a runAt because there are no more schedule entries + expect(runnerResult).toEqual({ state: {} }); + await taskRunner.cleanup(); + + // Verify all the expected calls were made before calling the rule executor + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalled(); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenCalledWith('siem.queryRule'); + expect(mockValidateRuleTypeParams).toHaveBeenCalled(); + // @ts-ignore - accessing private variable + // should run the first PENDING entry in the schedule + expect(taskRunner.scheduleToRunIndex).toEqual(4); + + // Verify all the expected calls were made while calling the rule executor + expect(RuleRunMetricsStore).toHaveBeenCalledTimes(1); + expect(ruleTypeWithAlerts.executor).toHaveBeenCalledTimes(1); + const call = ruleTypeWithAlerts.executor.mock.calls[0][0]; + + expect(call.startedAt).toStrictEqual(new Date(schedule5.runAt)); + + expect(clusterClient.bulk).toHaveBeenCalledTimes(1); + const bulkCall = clusterClient.bulk.mock.calls[0][0]; + + // @ts-ignore + expect(bulkCall.body[1][TIMESTAMP]).toEqual(schedule5.runAt); + // @ts-ignore + expect(bulkCall.body[1][ALERT_START]).toEqual(schedule5.runAt); + // @ts-ignore + expect(bulkCall.body[1][ALERT_TIME_RANGE]).toEqual({ gte: schedule5.runAt }); + // @ts-ignore + expect(bulkCall.body[1][ALERT_RULE_EXECUTION_TIMESTAMP]).toEqual(DATE_1970); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { + schedule: [ + { ...schedule1, status: adHocRunStatus.COMPLETE }, + { ...schedule2, status: adHocRunStatus.TIMEOUT }, + { ...schedule3, status: adHocRunStatus.ERROR }, + { ...schedule4, status: adHocRunStatus.COMPLETE }, + { ...schedule5, status: adHocRunStatus.COMPLETE }, + ], + }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + { refresh: false, namespace: undefined } + ); + + testAlertingEventLogCalls({ + status: 'ok', + backfillRunAt: schedule5.runAt, + backfillInterval: schedule5.interval, + logAlert: 2, + }); + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).nthCalledWith( + 1, + `Executing ad hoc run for rule test:rule-id for runAt ${schedule5.runAt}` + ); + expect(logger.debug).nthCalledWith( + 2, + `rule test:rule-id: 'test' has 1 active alerts: [{"instanceId":"1","actionGroup":"default"}]` + ); + expect(logger.error).not.toHaveBeenCalled(); + }); + + describe('error handling', () => { + test('should handle errors decrypting ad hoc rule run SO', async () => { + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockImplementationOnce(() => { + throw new Error('fail fail'); + }); + + const runnerResult = await taskRunner.run(); + // should not return a new runAt + expect(runnerResult).toEqual({ state: {} }); + await taskRunner.cleanup(); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + {} + ); + expect(ruleTypeRegistry.get).not.toHaveBeenCalled(); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).not.toHaveBeenCalled(); + expect(mockValidateRuleTypeParams).not.toHaveBeenCalled(); + // @ts-ignore - accessing private variable + // shouldn't have picked a schedule to run + expect(taskRunner.scheduleToRunIndex).toEqual(-1); + expect(RuleRunMetricsStore).not.toHaveBeenCalled(); + expect(ruleTypeWithAlerts.executor).not.toHaveBeenCalled(); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { status: adHocRunStatus.ERROR }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + { refresh: false, namespace: undefined } + ); + + testAlertingEventLogCalls({ + status: 'error', + errorMessage: 'fail fail', + errorReason: 'decrypt', + executionStatus: 'not-reached', + }); + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + + const loggerCall = logger.error.mock.calls[0][0]; + const loggerMeta = logger.error.mock.calls[0][1]; + const loggerCallPrefix = (loggerCall as string).split('-'); + expect(loggerCallPrefix[0].trim()).toMatchInlineSnapshot( + `"Executing ad hoc run with id \\"abc\\" has resulted in Error: fail fail"` + ); + expect(loggerMeta?.tags).toEqual(['abc', 'rule-ad-hoc-run-failed']); + expect(loggerMeta?.error?.stack_trace).toBeDefined(); + }); + + test('should handle errors when rule type is not found in the rule type registry', async () => { + ruleTypeRegistry.get.mockImplementationOnce(() => { + throw new Error('no rule type'); + }); + + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + + const runnerResult = await taskRunner.run(); + // should not return a new runAt + expect(runnerResult).toEqual({ state: {} }); + await taskRunner.cleanup(); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + {} + ); + expect(ruleTypeRegistry.get).toHaveBeenCalledWith('siem.queryRule'); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).not.toHaveBeenCalled(); + expect(mockValidateRuleTypeParams).not.toHaveBeenCalled(); + // @ts-ignore - accessing private variable + // shouldn't have picked a schedule to run + expect(taskRunner.scheduleToRunIndex).toEqual(-1); + expect(RuleRunMetricsStore).not.toHaveBeenCalled(); + expect(ruleTypeWithAlerts.executor).not.toHaveBeenCalled(); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { status: adHocRunStatus.ERROR }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + { refresh: false, namespace: undefined } + ); + + testAlertingEventLogCalls({ + status: 'error', + errorMessage: 'no rule type', + errorReason: 'read', + executionStatus: 'not-reached', + }); + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + + const loggerCall = logger.error.mock.calls[0][0]; + const loggerMeta = logger.error.mock.calls[0][1]; + const loggerCallPrefix = (loggerCall as string).split('-'); + expect(loggerCallPrefix[0].trim()).toMatchInlineSnapshot( + `"Executing ad hoc run with id \\"abc\\" has resulted in Error: no rule type"` + ); + expect(loggerMeta?.tags).toEqual(['abc', 'rule-ad-hoc-run-failed']); + expect(loggerMeta?.error?.stack_trace).toBeDefined(); + }); + + test('should handle errors when rule type is not enabled', async () => { + ruleTypeRegistry.ensureRuleTypeEnabled.mockImplementationOnce(() => { + throw new Error('rule type not enabled'); + }); + + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + + const runnerResult = await taskRunner.run(); + // should not return a new runAt + expect(runnerResult).toEqual({ state: {} }); + await taskRunner.cleanup(); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + {} + ); + expect(ruleTypeRegistry.get).toHaveBeenCalledWith('siem.queryRule'); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenCalledWith('siem.queryRule'); + expect(mockValidateRuleTypeParams).not.toHaveBeenCalled(); + // @ts-ignore - accessing private variable + // shouldn't have picked a schedule to run + expect(taskRunner.scheduleToRunIndex).toEqual(-1); + expect(RuleRunMetricsStore).not.toHaveBeenCalled(); + expect(ruleTypeWithAlerts.executor).not.toHaveBeenCalled(); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { status: adHocRunStatus.ERROR }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + { refresh: false, namespace: undefined } + ); + + testAlertingEventLogCalls({ + status: 'error', + errorMessage: 'rule type not enabled', + errorReason: 'license', + executionStatus: 'not-reached', + }); + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + + const loggerCall = logger.error.mock.calls[0][0]; + const loggerMeta = logger.error.mock.calls[0][1]; + const loggerCallPrefix = (loggerCall as string).split('-'); + expect(loggerCallPrefix[0].trim()).toMatchInlineSnapshot( + `"Executing ad hoc run with id \\"abc\\" has resulted in Error: rule type not enabled"` + ); + expect(loggerMeta?.tags).toEqual(['abc', 'rule-ad-hoc-run-failed', 'test', 'rule-id']); + expect(loggerMeta?.error?.stack_trace).toBeDefined(); + }); + + test('should handle errors when rule type params are not valid', async () => { + mockValidateRuleTypeParams.mockImplementationOnce(() => { + throw new Error('params not valid'); + }); + + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + + const runnerResult = await taskRunner.run(); + // should not return a new runAt + expect(runnerResult).toEqual({ state: {} }); + await taskRunner.cleanup(); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + {} + ); + expect(ruleTypeRegistry.get).toHaveBeenCalledWith('siem.queryRule'); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenCalledWith('siem.queryRule'); + expect(mockValidateRuleTypeParams).toHaveBeenCalledWith( + mockedAdHocRunSO.attributes.rule.params, + ruleTypeWithAlerts.validate.params + ); // @ts-ignore - accessing private variable + // shouldn't have picked a schedule to run + expect(taskRunner.scheduleToRunIndex).toEqual(-1); + expect(RuleRunMetricsStore).not.toHaveBeenCalled(); + expect(ruleTypeWithAlerts.executor).not.toHaveBeenCalled(); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { status: adHocRunStatus.ERROR }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + { refresh: false, namespace: undefined } + ); + + testAlertingEventLogCalls({ + status: 'error', + errorMessage: 'params not valid', + errorReason: 'validate', + executionStatus: 'not-reached', + }); + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(1); + + const loggerCall = logger.error.mock.calls[0][0]; + const loggerMeta = logger.error.mock.calls[0][1]; + const loggerCallPrefix = (loggerCall as string).split('-'); + expect(loggerCallPrefix[0].trim()).toMatchInlineSnapshot( + `"Executing ad hoc run with id \\"abc\\" has resulted in Error: params not valid"` + ); + expect(loggerMeta?.tags).toEqual(['abc', 'rule-ad-hoc-run-failed', 'test', 'rule-id']); + expect(loggerMeta?.error?.stack_trace).toBeDefined(); + }); + + test('should handle errors when rule type executor throws error', async () => { + ruleTypeWithAlerts.executor.mockImplementationOnce(() => { + throw new Error('executor failed'); + }); + + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + + const runnerResult = await taskRunner.run(); + // should return a new runAt to try to next scheduled execution + expect(runnerResult).toEqual({ state: {}, runAt: new Date('1970-01-01T00:00:00.000Z') }); + await taskRunner.cleanup(); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + {} + ); + expect(ruleTypeRegistry.get).toHaveBeenCalledWith('siem.queryRule'); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenCalledWith('siem.queryRule'); + expect(mockValidateRuleTypeParams).toHaveBeenCalledWith( + mockedAdHocRunSO.attributes.rule.params, + ruleTypeWithAlerts.validate.params + ); + + // @ts-ignore - accessing private variable + // should run the first entry in the schedule + expect(taskRunner.scheduleToRunIndex).toEqual(0); + expect(RuleRunMetricsStore).toHaveBeenCalledTimes(1); + expect(ruleTypeWithAlerts.executor).toHaveBeenCalledTimes(1); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { + schedule: [ + { ...schedule1, status: adHocRunStatus.ERROR }, + schedule2, + schedule3, + schedule4, + schedule5, + ], + }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + + testAlertingEventLogCalls({ + status: 'error', + errorMessage: 'executor failed', + errorReason: 'execute', + executionStatus: 'failed', + backfillRunAt: schedule1.runAt, + backfillInterval: schedule1.interval, + }); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).nthCalledWith( + 1, + `Executing ad hoc run for rule test:rule-id for runAt ${schedule1.runAt}` + ); + expect(logger.error).toHaveBeenCalledTimes(1); + + const loggerCall = logger.error.mock.calls[0][0]; + const loggerMeta = logger.error.mock.calls[0][1]; + const loggerCallPrefix = (loggerCall as string).split('-'); + expect(loggerCallPrefix[0].trim()).toMatchInlineSnapshot( + `"Executing ad hoc run with id \\"abc\\" has resulted in Error: executor failed"` + ); + expect(loggerMeta?.tags).toEqual(['abc', 'rule-ad-hoc-run-failed', 'test', 'rule-id']); + expect(loggerMeta?.error?.stack_trace).toBeDefined(); + }); + + test('should log if error deleting ad hoc rule run SO after done', async () => { + internalSavedObjectsRepository.delete.mockImplementationOnce(() => { + throw new Error('trouble deleting this'); + }); + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + ...mockedAdHocRunSO, + attributes: { + ...mockedAdHocRunSO.attributes, + schedule: [ + { ...schedule1, status: adHocRunStatus.COMPLETE }, + { ...schedule2, status: adHocRunStatus.TIMEOUT }, + { ...schedule3, status: adHocRunStatus.ERROR }, + { ...schedule4, status: adHocRunStatus.COMPLETE }, + schedule5, + ], + }, + }); + + const runnerResult = await taskRunner.run(); + // should not return a runAt because there are no more schedule entries + expect(runnerResult).toEqual({ state: {} }); + await taskRunner.cleanup(); + + // Verify all the expected calls were made before calling the rule executor + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalled(); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenCalledWith('siem.queryRule'); + expect(mockValidateRuleTypeParams).toHaveBeenCalled(); + // @ts-ignore - accessing private variable + // should run the first PENDING entry in the schedule + expect(taskRunner.scheduleToRunIndex).toEqual(4); + + // Verify all the expected calls were made while calling the rule executor + expect(RuleRunMetricsStore).toHaveBeenCalledTimes(1); + expect(ruleTypeWithAlerts.executor).toHaveBeenCalledTimes(1); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { + schedule: [ + { ...schedule1, status: adHocRunStatus.COMPLETE }, + { ...schedule2, status: adHocRunStatus.TIMEOUT }, + { ...schedule3, status: adHocRunStatus.ERROR }, + { ...schedule4, status: adHocRunStatus.COMPLETE }, + { ...schedule5, status: adHocRunStatus.COMPLETE }, + ], + }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + { refresh: false, namespace: undefined } + ); + + testAlertingEventLogCalls({ + status: 'ok', + backfillRunAt: schedule5.runAt, + backfillInterval: schedule5.interval, + }); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).nthCalledWith( + 1, + `Executing ad hoc run for rule test:rule-id for runAt ${schedule5.runAt}` + ); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).nthCalledWith( + 1, + `Failed to cleanup ad_hoc_run_params object [id="abc"]: trouble deleting this` + ); + }); + }); + + describe('timeout', () => { + test('should handle task cancellation signal due to timeout', async () => { + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + + const promise = taskRunner.run(); + await Promise.resolve(); + await taskRunner.cancel(); + await promise; + await taskRunner.cleanup(); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + {} + ); + expect(ruleTypeRegistry.get).toHaveBeenCalledWith('siem.queryRule'); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenCalledWith('siem.queryRule'); + expect(mockValidateRuleTypeParams).toHaveBeenCalledWith( + mockedAdHocRunSO.attributes.rule.params, + ruleTypeWithAlerts.validate.params + ); + + // @ts-ignore - accessing private variable + // should run the first entry in the schedule + expect(taskRunner.scheduleToRunIndex).toEqual(0); + expect(RuleRunMetricsStore).toHaveBeenCalledTimes(1); + expect(ruleTypeWithAlerts.executor).toHaveBeenCalledTimes(1); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { + schedule: [ + { ...schedule1, status: adHocRunStatus.TIMEOUT }, + schedule2, + schedule3, + schedule4, + schedule5, + ], + }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + + testAlertingEventLogCalls({ + status: 'ok', + timeout: true, + backfillRunAt: schedule1.runAt, + backfillInterval: schedule1.interval, + }); + expect(logger.debug).toHaveBeenCalledTimes(3); + expect(logger.debug).nthCalledWith( + 1, + `Executing ad hoc run for rule test:rule-id for runAt ${schedule1.runAt}` + ); + expect(logger.debug).nthCalledWith( + 2, + `Cancelling execution for ad hoc run with id abc for rule type test with id rule-id - execution exceeded rule type timeout of 3m` + ); + expect(logger.debug).nthCalledWith( + 3, + `Aborting any in-progress ES searches for rule type test with id rule-id` + ); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test('should handle task cancellation that leads to executor throwing error', async () => { + ruleTypeWithAlerts.executor.mockImplementationOnce(() => { + throw new Error('Search has been aborted due to cancelled execution'); + }); + + const taskRunner = new AdHocTaskRunner({ + context: taskRunnerFactoryInitializerParams, + internalSavedObjectsRepository, + taskInstance: mockedTaskInstance, + }); + + const promise = taskRunner.run(); + await Promise.resolve(); + await taskRunner.cancel(); + await promise; + await taskRunner.cleanup(); + + expect(encryptedSavedObjectsClient.getDecryptedAsInternalUser).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + 'abc', + {} + ); + expect(ruleTypeRegistry.get).toHaveBeenCalledWith('siem.queryRule'); + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenCalledWith('siem.queryRule'); + expect(mockValidateRuleTypeParams).toHaveBeenCalledWith( + mockedAdHocRunSO.attributes.rule.params, + ruleTypeWithAlerts.validate.params + ); + + // @ts-ignore - accessing private variable + // should run the first entry in the schedule + expect(taskRunner.scheduleToRunIndex).toEqual(0); + expect(RuleRunMetricsStore).toHaveBeenCalledTimes(1); + expect(ruleTypeWithAlerts.executor).toHaveBeenCalledTimes(1); + + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + mockedAdHocRunSO.id, + { + schedule: [ + { ...schedule1, status: adHocRunStatus.TIMEOUT }, + schedule2, + schedule3, + schedule4, + schedule5, + ], + }, + { namespace: undefined, refresh: false } + ); + + expect(internalSavedObjectsRepository.delete).not.toHaveBeenCalled(); + + testAlertingEventLogCalls({ + status: 'error', + errorMessage: 'Search has been aborted due to cancelled execution', + errorReason: 'execute', + executionStatus: 'failed', + backfillRunAt: schedule1.runAt, + backfillInterval: schedule1.interval, + timeout: true, + }); + expect(logger.debug).toHaveBeenCalledTimes(3); + expect(logger.debug).nthCalledWith( + 1, + `Executing ad hoc run for rule test:rule-id for runAt ${schedule1.runAt}` + ); + expect(logger.debug).nthCalledWith( + 2, + `Cancelling execution for ad hoc run with id abc for rule type test with id rule-id - execution exceeded rule type timeout of 3m` + ); + expect(logger.debug).nthCalledWith( + 3, + `Aborting any in-progress ES searches for rule type test with id rule-id` + ); + expect(logger.error).toHaveBeenCalledTimes(1); + + const loggerCall = logger.error.mock.calls[0][0]; + const loggerMeta = logger.error.mock.calls[0][1]; + const loggerCallPrefix = (loggerCall as string).split('-'); + expect(loggerCallPrefix[0].trim()).toMatchInlineSnapshot( + `"Executing ad hoc run with id \\"abc\\" has resulted in Error: Search has been aborted due to cancelled execution"` + ); + expect(loggerMeta?.tags).toEqual(['abc', 'rule-ad-hoc-run-failed', 'test', 'rule-id']); + expect(loggerMeta?.error?.stack_trace).toBeDefined(); + }); + }); + + function testAlertingEventLogCalls({ + context = alertingEventLoggerInitializer, + backfillRunAt, + backfillInterval, + activeAlerts = 0, + newAlerts = 0, + recoveredAlerts = 0, + triggeredActions = 0, + generatedActions = 0, + status, + errorReason, + errorMessage = 'GENERIC ERROR MESSAGE', + executionStatus = 'succeeded', + logAlert = 0, + hasReachedAlertLimit = false, + hasReachedQueuedActionsLimit = false, + timeout = false, + }: { + status: string; + backfillRunAt?: string; + backfillInterval?: string; + context?: ContextOpts; + activeAlerts?: number; + newAlerts?: number; + recoveredAlerts?: number; + triggeredActions?: number; + generatedActions?: number; + executionStatus?: 'succeeded' | 'failed' | 'not-reached'; + logAlert?: number; + errorReason?: string; + errorMessage?: string; + hasReachedAlertLimit?: boolean; + hasReachedQueuedActionsLimit?: boolean; + timeout?: boolean; + }) { + expect(alertingEventLogger.initialize).toHaveBeenCalledWith({ + context, + runDate: new Date(DATE_1970), + type: executionType.BACKFILL, + }); + if (errorReason === 'decrypt') { + expect(alertingEventLogger.addOrUpdateRuleData).not.toHaveBeenCalled(); + } else if (errorReason === 'read') { + expect(alertingEventLogger.addOrUpdateRuleData).not.toHaveBeenCalled(); + } else { + expect(alertingEventLogger.addOrUpdateRuleData).toHaveBeenCalledWith({ + id: RULE_ID, + type: ruleTypeWithAlerts, + name: mockedAdHocRunSO.attributes.rule.name, + consumer: mockedAdHocRunSO.attributes.rule.consumer, + revision: mockedAdHocRunSO.attributes.rule.revision, + }); + } + + if (executionStatus === 'succeeded') { + expect(alertingEventLogger.setExecutionSucceeded).toHaveBeenCalledWith( + `rule executed: test:rule-id: 'test'` + ); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + } else if (executionStatus === 'failed') { + expect(alertingEventLogger.setExecutionFailed).toHaveBeenCalledWith( + `rule execution failure: test:rule-id: 'test'`, + errorMessage + ); + expect(alertingEventLogger.setExecutionSucceeded).not.toHaveBeenCalled(); + } else if (executionStatus === 'not-reached') { + expect(alertingEventLogger.setExecutionSucceeded).not.toHaveBeenCalled(); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + } + + expect(alertingEventLogger.setMaintenanceWindowIds).not.toHaveBeenCalled(); + + if (logAlert > 0) { + expect(alertingEventLogger.logAlert).toHaveBeenCalledTimes(logAlert); + } else { + expect(alertingEventLogger.logAlert).not.toHaveBeenCalled(); + } + + expect(alertingEventLogger.getStartAndDuration).toHaveBeenCalled(); + + if (status === 'error') { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + backfill: { + id: mockedAdHocRunSO.id, + ...(backfillRunAt ? { start: backfillRunAt } : {}), + ...(backfillInterval ? { interval: backfillInterval } : {}), + }, + metrics: null, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + error: { + message: errorMessage, + reason: errorReason, + }, + }, + timings: { + claim_to_start_duration_ms: expect.any(Number), + persist_alerts_duration_ms: 0, + prepare_rule_duration_ms: 0, + process_alerts_duration_ms: 0, + process_rule_duration_ms: 0, + rule_type_run_duration_ms: 0, + total_run_duration_ms: expect.any(Number), + trigger_actions_duration_ms: 0, + }, + }); + } else if (status === 'warning') { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: { + esSearchDurationMs: 33, + numSearches: 3, + numberOfActiveAlerts: activeAlerts, + numberOfGeneratedActions: generatedActions, + numberOfNewAlerts: newAlerts, + numberOfRecoveredAlerts: recoveredAlerts, + numberOfTriggeredActions: triggeredActions, + numberOfDelayedAlerts: 0, + totalSearchDurationMs: 23423, + hasReachedAlertLimit, + triggeredActionsStatus: 'partial', + hasReachedQueuedActionsLimit, + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + warning: { + message: `The maximum number of actions for this rule type was reached; excess actions were not triggered.`, + reason: errorReason, + }, + }, + timings: { + claim_to_start_duration_ms: 0, + persist_alerts_duration_ms: 0, + prepare_rule_duration_ms: 0, + process_alerts_duration_ms: 0, + process_rule_duration_ms: 0, + rule_type_run_duration_ms: 0, + total_run_duration_ms: 0, + trigger_actions_duration_ms: 0, + }, + }); + } else if (status === 'skip') { + expect(alertingEventLogger.done).not.toHaveBeenCalled(); + } else { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + backfill: { + id: mockedAdHocRunSO.id, + ...(backfillRunAt ? { start: backfillRunAt } : {}), + ...(backfillInterval ? { interval: backfillInterval } : {}), + }, + metrics: { + numSearches: 3, + totalSearchDurationMs: 23423, + esSearchDurationMs: 33, + numberOfTriggeredActions: 0, + numberOfGeneratedActions: 0, + numberOfActiveAlerts: 0, + numberOfRecoveredAlerts: 0, + numberOfNewAlerts: 0, + hasReachedAlertLimit: false, + triggeredActionsStatus: 'complete', + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + }, + timings: { + claim_to_start_duration_ms: expect.any(Number), + persist_alerts_duration_ms: 0, + prepare_rule_duration_ms: 0, + process_alerts_duration_ms: 0, + process_rule_duration_ms: 0, + rule_type_run_duration_ms: 0, + total_run_duration_ms: expect.any(Number), + trigger_actions_duration_ms: 0, + }, + }); + } + + if (timeout) { + expect(alertingEventLogger.logTimeout).toHaveBeenCalled(); + } else { + expect(alertingEventLogger.logTimeout).not.toHaveBeenCalled(); + } + } +}); diff --git a/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_runner.ts b/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_runner.ts new file mode 100644 index 0000000000000..bccf28a7cb7bc --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_runner.ts @@ -0,0 +1,597 @@ +/* + * 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 apm from 'elastic-apm-node'; +import { v4 as uuidv4 } from 'uuid'; +import { + ISavedObjectsRepository, + KibanaRequest, + Logger, + SavedObject, + SavedObjectsErrorHelpers, +} from '@kbn/core/server'; +import { + ConcreteTaskInstance, + createTaskRunError, + TaskErrorSource, +} from '@kbn/task-manager-plugin/server'; +import { nanosToMillis } from '@kbn/event-log-plugin/common'; +import { RunResult } from '@kbn/task-manager-plugin/server/task'; +import { AdHocRunStatus, adHocRunStatus } from '../../common/constants'; +import { RuleRunnerErrorStackTraceLog, RuleTaskStateAndMetrics, TaskRunnerContext } from './types'; +import { getExecutorServices } from './get_executor_services'; +import { ErrorWithReason, validateRuleTypeParams } from '../lib'; +import { + AlertInstanceContext, + AlertInstanceState, + RuleAlertData, + RuleExecutionStatusErrorReasons, + RuleTypeParams, + RuleTypeRegistry, + RuleTypeState, +} from '../types'; +import { TaskRunnerTimer, TaskRunnerTimerSpan } from './task_runner_timer'; +import { AdHocRun, AdHocRunSchedule, AdHocRunSO } from '../data/ad_hoc_run/types'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { RuleMonitoringService } from '../monitoring/rule_monitoring_service'; +import { AdHocTaskRunningHandler } from './ad_hoc_task_running_handler'; +import { getFakeKibanaRequest } from './rule_loader'; +import { RuleResultService } from '../monitoring/rule_result_service'; +import { RuleTypeRunner } from './rule_type_runner'; +import { initializeAlertsClient } from '../alerts_client'; +import { partiallyUpdateAdHocRun, processRunResults } from './lib'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; +import { + AlertingEventLogger, + executionType, +} from '../lib/alerting_event_logger/alerting_event_logger'; +import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { getEsErrorMessage } from '../lib/errors'; +import { Result, isOk, asOk, asErr } from '../lib/result_type'; + +interface ConstructorParams { + context: TaskRunnerContext; + internalSavedObjectsRepository: ISavedObjectsRepository; + taskInstance: ConcreteTaskInstance; +} + +interface RunParams { + adHocRunData: AdHocRun; + fakeRequest: KibanaRequest; + scheduleToRun: AdHocRunSchedule | null; + validatedParams: RuleTypeParams; +} + +export class AdHocTaskRunner { + private readonly context: TaskRunnerContext; + private readonly executionId: string; + private readonly internalSavedObjectsRepository: ISavedObjectsRepository; + private readonly ruleTypeRegistry: RuleTypeRegistry; + private readonly taskInstance: ConcreteTaskInstance; + + private adHocRunSchedule: AdHocRunSchedule[] = []; + private alertingEventLogger: AlertingEventLogger; + private cancelled: boolean = false; + private logger: Logger; + private ruleId: string = ''; + private ruleMonitoring: RuleMonitoringService; + private ruleResult: RuleResultService; + private ruleTypeId: string = ''; + private ruleTypeRunner: RuleTypeRunner< + RuleTypeParams, + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + string, + string, + RuleAlertData + >; + private runDate = new Date(); + private scheduleToRunIndex: number = -1; + private searchAbortController: AbortController; + private shouldDeleteTask: boolean = false; + private stackTraceLog: RuleRunnerErrorStackTraceLog | null = null; + private taskRunning: AdHocTaskRunningHandler; + private timer: TaskRunnerTimer; + + constructor({ context, internalSavedObjectsRepository, taskInstance }: ConstructorParams) { + this.context = context; + this.executionId = uuidv4(); + this.internalSavedObjectsRepository = internalSavedObjectsRepository; + this.ruleTypeRegistry = context.ruleTypeRegistry; + this.taskInstance = taskInstance; + + this.alertingEventLogger = new AlertingEventLogger(this.context.eventLogger); + this.logger = context.logger.get(`ad_hoc_run`); + this.ruleMonitoring = new RuleMonitoringService(); + this.ruleResult = new RuleResultService(); + this.timer = new TaskRunnerTimer({ logger: this.logger }); + this.ruleTypeRunner = new RuleTypeRunner< + RuleTypeParams, + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + string, + string, + RuleAlertData + >({ + context: this.context, + logger: this.logger, + task: this.taskInstance, + timer: this.timer, + }); + this.searchAbortController = new AbortController(); + this.taskRunning = new AdHocTaskRunningHandler( + this.internalSavedObjectsRepository, + this.logger + ); + } + + private async updateAdHocRunSavedObjectPostRun( + adHocRunParamsId: string, + namespace: string | undefined, + { status, schedule }: { status?: AdHocRunStatus; schedule?: AdHocRunSchedule[] } + ) { + try { + // Checking to see if the update performed at the beginning + // of the run is complete. Swallowing the error because we still + // want to move forward with the update post-run + await this.taskRunning.waitFor(); + // eslint-disable-next-line no-empty + } catch {} + + try { + await partiallyUpdateAdHocRun( + this.internalSavedObjectsRepository, + adHocRunParamsId, + { ...(status ? { status } : {}), ...(schedule ? { schedule } : {}) }, + { + ignore404: true, + namespace, + refresh: false, + } + ); + } catch (err) { + this.logger.error(`error updating ad hoc run ${adHocRunParamsId} ${err.message}`); + } + } + + private async runRule({ + adHocRunData, + fakeRequest, + scheduleToRun, + validatedParams: params, + }: RunParams): Promise { + const ruleRunMetricsStore = new RuleRunMetricsStore(); + if (scheduleToRun == null) { + return ruleRunMetricsStore.getMetrics(); + } + + const { rule } = adHocRunData; + const ruleType = this.ruleTypeRegistry.get(rule.alertTypeId); + + const ruleLabel = `${ruleType.id}:${rule.id}: '${rule.name}'`; + const ruleTypeRunnerContext = { + alertingEventLogger: this.alertingEventLogger, + namespace: this.context.spaceIdToNamespace(adHocRunData.spaceId), + ruleId: rule.id, + ruleLogPrefix: ruleLabel, + ruleRunMetricsStore, + spaceId: adHocRunData.spaceId, + }; + const alertsClient = await initializeAlertsClient< + RuleTypeParams, + RuleAlertData, + AlertInstanceState, + AlertInstanceContext, + string, + string + >({ + alertsService: this.context.alertsService, + context: ruleTypeRunnerContext, + executionId: this.executionId, + logger: this.logger, + maxAlerts: this.context.maxAlerts, + rule: { + id: rule.id, + name: rule.name, + tags: rule.tags, + consumer: rule.consumer, + revision: rule.revision, + params: rule.params, + }, + ruleType, + runTimestamp: this.runDate, + startedAt: new Date(scheduleToRun.runAt), + taskInstance: this.taskInstance, + }); + + const executorServices = await getExecutorServices({ + context: this.context, + fakeRequest, + abortController: this.searchAbortController, + logger: this.logger, + ruleMonitoringService: this.ruleMonitoring, + ruleResultService: this.ruleResult, + ruleData: { + name: rule.name, + alertTypeId: rule.alertTypeId, + id: rule.id, + spaceId: adHocRunData.spaceId, + }, + ruleTaskTimeout: ruleType.ruleTaskTimeout, + }); + + const { error, stackTrace } = await this.ruleTypeRunner.run({ + context: ruleTypeRunnerContext, + alertsClient, + executionId: this.executionId, + executorServices, + rule: { + ...rule, + actions: [], + muteAll: false, + createdAt: new Date(rule.createdAt), + updatedAt: new Date(rule.updatedAt), + }, + ruleType, + startedAt: new Date(scheduleToRun.runAt), + state: this.taskInstance.state, + validatedParams: params, + }); + + // if there was an error, save the stack trace and throw + if (error) { + this.stackTraceLog = stackTrace ?? null; + throw error; + } + + return ruleRunMetricsStore.getMetrics(); + } + + /** + * Before we actually kick off the ad hoc run: + * - read decrypted ad hoc run SO + * - start the RunningHandler + * - initialize the event logger + * - set the current APM transaction info + * - validate that rule type is enabled and params are valid + */ + private async prepareToRun(): Promise { + this.runDate = new Date(); + return await this.timer.runWithTimer(TaskRunnerTimerSpan.PrepareRule, async () => { + const { + params: { adHocRunParamsId, spaceId }, + startedAt, + } = this.taskInstance; + + const namespace = this.context.spaceIdToNamespace(spaceId); + + this.alertingEventLogger.initialize({ + context: { + savedObjectId: adHocRunParamsId, + savedObjectType: AD_HOC_RUN_SAVED_OBJECT_TYPE, + spaceId, + executionId: this.executionId, + taskScheduledAt: this.taskInstance.scheduledAt, + ...(namespace ? { namespace } : {}), + }, + runDate: this.runDate, + // in the future we might want different types of ad hoc runs (like preview) + type: executionType.BACKFILL, + }); + + let adHocRunData: AdHocRun; + + try { + const adHocRunSO: SavedObject = + await this.context.encryptedSavedObjectsClient.getDecryptedAsInternalUser( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + adHocRunParamsId, + { namespace } + ); + + adHocRunData = { + id: adHocRunSO.id, + ...adHocRunSO.attributes, + rule: { + ...adHocRunSO.attributes.rule, + id: adHocRunSO.references[0].id, + }, + }; + } catch (err) { + const errorSource = SavedObjectsErrorHelpers.isNotFoundError(err) + ? TaskErrorSource.USER + : TaskErrorSource.FRAMEWORK; + + throw createTaskRunError( + new ErrorWithReason(RuleExecutionStatusErrorReasons.Decrypt, err), + errorSource + ); + } + + const { rule, apiKeyToUse, schedule } = adHocRunData; + + let ruleType: UntypedNormalizedRuleType; + try { + ruleType = this.ruleTypeRegistry.get(rule.alertTypeId); + } catch (err) { + throw createTaskRunError( + new ErrorWithReason(RuleExecutionStatusErrorReasons.Read, err), + TaskErrorSource.FRAMEWORK + ); + } + + this.ruleTypeId = ruleType.id; + this.ruleId = rule.id; + this.alertingEventLogger.addOrUpdateRuleData({ + id: rule.id, + type: ruleType, + name: rule.name, + consumer: rule.consumer, + revision: rule.revision, + }); + + try { + this.ruleTypeRegistry.ensureRuleTypeEnabled(rule.alertTypeId); + } catch (err) { + throw createTaskRunError( + new ErrorWithReason(RuleExecutionStatusErrorReasons.License, err), + TaskErrorSource.USER + ); + } + + let validatedParams: RuleTypeParams; + try { + validatedParams = validateRuleTypeParams( + rule.params, + ruleType.validate.params + ); + } catch (err) { + throw createTaskRunError( + new ErrorWithReason(RuleExecutionStatusErrorReasons.Validate, err), + TaskErrorSource.USER + ); + } + + if (apm.currentTransaction) { + apm.currentTransaction.name = `Execute Backfill for Alerting Rule`; + apm.currentTransaction.addLabels({ + alerting_rule_space_id: spaceId, + alerting_rule_id: rule.id, + alerting_rule_consumer: rule.consumer, + alerting_rule_name: rule.name, + alerting_rule_tags: rule.tags.join(', '), + alerting_rule_type_id: rule.alertTypeId, + alerting_rule_params: JSON.stringify(rule.params), + }); + } + + if (startedAt) { + // Capture how long it took for the task to start running after being claimed + this.timer.setDuration(TaskRunnerTimerSpan.StartTaskRun, startedAt); + } + + // Determine which schedule entry we're going to run + // Find the first index where the status is pending + this.adHocRunSchedule = schedule; + this.scheduleToRunIndex = (this.adHocRunSchedule ?? []).findIndex( + (s: AdHocRunSchedule) => s.status === adHocRunStatus.PENDING + ); + if (this.scheduleToRunIndex > -1) { + this.logger.debug( + `Executing ad hoc run for rule ${ruleType.id}:${rule.id} for runAt ${ + this.adHocRunSchedule[this.scheduleToRunIndex].runAt + }` + ); + this.adHocRunSchedule[this.scheduleToRunIndex].status = adHocRunStatus.RUNNING; + this.taskRunning.start( + adHocRunParamsId, + schedule, + this.context.spaceIdToNamespace(spaceId) + ); + } + + // Generate fake request with API key + const fakeRequest = getFakeKibanaRequest(this.context, spaceId, apiKeyToUse); + + return { + adHocRunData, + fakeRequest, + scheduleToRun: + this.scheduleToRunIndex > -1 ? this.adHocRunSchedule[this.scheduleToRunIndex] : null, + validatedParams, + }; + }); + } + + private async processAdHocRunResults(ruleRunMetrics: Result) { + const { + params: { adHocRunParamsId, spaceId }, + startedAt, + } = this.taskInstance; + const namespace = this.context.spaceIdToNamespace(spaceId); + + const { executionStatus: execStatus, executionMetrics: execMetrics } = + await this.timer.runWithTimer(TaskRunnerTimerSpan.ProcessRuleRun, async () => { + const { executionStatus, executionMetrics, outcome } = processRunResults({ + result: this.ruleResult, + runDate: this.runDate, + runResultWithMetrics: ruleRunMetrics, + }); + + if (!isOk(ruleRunMetrics)) { + const error = this.stackTraceLog ? this.stackTraceLog.message : ruleRunMetrics.error; + const stack = this.stackTraceLog + ? this.stackTraceLog.stackTrace + : ruleRunMetrics.error.stack; + const message = `Executing ad hoc run with id "${adHocRunParamsId}" has resulted in Error: ${getEsErrorMessage( + error + )} - ${stack ?? ''}`; + const tags = [adHocRunParamsId, 'rule-ad-hoc-run-failed']; + if (this.ruleTypeId.length > 0) { + tags.push(this.ruleTypeId); + } + if (this.ruleId.length > 0) { + tags.push(this.ruleId); + } + this.logger.error(message, { tags, error: { stack_trace: stack } }); + } + + if (apm.currentTransaction) { + apm.currentTransaction.setOutcome(outcome); + } + + // set start and duration based on event log + const { start, duration } = this.alertingEventLogger.getStartAndDuration(); + if (null != start) { + executionStatus.lastExecutionDate = start; + } + if (null != duration) { + executionStatus.lastDuration = nanosToMillis(duration); + } + + if (this.scheduleToRunIndex > -1) { + let updatedStatus: AdHocRunStatus = adHocRunStatus.COMPLETE; + if (this.cancelled) { + updatedStatus = adHocRunStatus.TIMEOUT; + } else if (outcome === 'failure') { + updatedStatus = adHocRunStatus.ERROR; + } + this.adHocRunSchedule[this.scheduleToRunIndex].status = updatedStatus; + } + + // If execution failed due to decrypt error, we should stop running the task + // If the user wants to rerun it, they can reschedule + // In the future, we can consider saving the task in an error state when we + // have one or both of the following abilities + // - ability to rerun a failed ad hoc run + // - ability to clean up failed ad hoc runs (either manually or automatically) + this.shouldDeleteTask = + executionStatus.status === 'error' && + (executionStatus?.error?.reason === RuleExecutionStatusErrorReasons.Decrypt || + executionStatus?.error?.reason === RuleExecutionStatusErrorReasons.Read || + executionStatus?.error?.reason === RuleExecutionStatusErrorReasons.License || + executionStatus?.error?.reason === RuleExecutionStatusErrorReasons.Validate); + + await this.updateAdHocRunSavedObjectPostRun(adHocRunParamsId, namespace, { + ...(this.shouldDeleteTask ? { status: adHocRunStatus.ERROR } : {}), + ...(this.scheduleToRunIndex > -1 ? { schedule: this.adHocRunSchedule } : {}), + }); + + if (startedAt) { + // Capture how long it took for the rule to run after being claimed + this.timer.setDuration(TaskRunnerTimerSpan.TotalRunDuration, startedAt); + } + return { executionStatus, executionMetrics }; + }); + this.alertingEventLogger.done({ + status: execStatus, + metrics: execMetrics, + // in the future if we have other types of ad hoc runs (like preview) + // we can differentiate and pass in different info + backfill: { + id: adHocRunParamsId, + start: + this.scheduleToRunIndex > -1 + ? this.adHocRunSchedule[this.scheduleToRunIndex].runAt + : undefined, + interval: + this.scheduleToRunIndex > -1 + ? this.adHocRunSchedule[this.scheduleToRunIndex].interval + : undefined, + }, + timings: this.timer.toJson(), + }); + } + + private hasAnyPendingRuns(): boolean { + let hasPendingRuns = false; + const anyPendingRuns = this.adHocRunSchedule.findIndex( + (s: AdHocRunSchedule) => s.status === adHocRunStatus.PENDING + ); + if (anyPendingRuns > -1) { + hasPendingRuns = true; + } + return hasPendingRuns; + } + + async run(): Promise { + let runMetrics: Result; + try { + const runParams = await this.prepareToRun(); + runMetrics = asOk({ metrics: await this.runRule(runParams) }); + } catch (err) { + runMetrics = asErr(err); + } + await this.processAdHocRunResults(runMetrics); + + this.shouldDeleteTask = this.shouldDeleteTask || !this.hasAnyPendingRuns(); + + return { + state: {}, + ...(this.shouldDeleteTask ? {} : { runAt: new Date() }), + }; + } + + async cancel(): Promise { + if (this.cancelled) { + return; + } + this.cancelled = true; + this.searchAbortController.abort(); + this.ruleTypeRunner.cancelRun(); + + // Write event log entry + const { + params: { adHocRunParamsId }, + timeoutOverride, + } = this.taskInstance; + + this.logger.debug( + `Cancelling execution for ad hoc run with id ${adHocRunParamsId} for rule type ${this.ruleTypeId} with id ${this.ruleId} - execution exceeded rule type timeout of ${timeoutOverride}` + ); + this.logger.debug( + `Aborting any in-progress ES searches for rule type ${this.ruleTypeId} with id ${this.ruleId}` + ); + this.alertingEventLogger.logTimeout({ + backfill: { + id: adHocRunParamsId, + start: + this.scheduleToRunIndex > -1 + ? this.adHocRunSchedule[this.scheduleToRunIndex].runAt + : undefined, + interval: + this.scheduleToRunIndex > -1 + ? this.adHocRunSchedule[this.scheduleToRunIndex].interval + : undefined, + }, + }); + } + + async cleanup() { + if (!this.shouldDeleteTask) return; + + try { + await this.internalSavedObjectsRepository.delete( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + this.taskInstance.params.adHocRunParamsId, + { + refresh: false, + namespace: this.context.spaceIdToNamespace(this.taskInstance.params.spaceId), + } + ); + } catch (e) { + // Log error only, we shouldn't fail the task because of an error here (if ever there's retry logic) + this.logger.error( + `Failed to cleanup ${AD_HOC_RUN_SAVED_OBJECT_TYPE} object [id="${this.taskInstance.params.adHocRunParamsId}"]: ${e.message}` + ); + } + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_running_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_running_handler.test.ts new file mode 100644 index 0000000000000..62e64e02cbecd --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_running_handler.test.ts @@ -0,0 +1,112 @@ +/* + * 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 { ISavedObjectsRepository, Logger } from '@kbn/core/server'; + +import { partiallyUpdateAdHocRun } from './lib'; +import { AdHocTaskRunningHandler } from './ad_hoc_task_running_handler'; +import { adHocRunStatus } from '../../common/constants'; + +jest.mock('./lib', () => ({ + partiallyUpdateAdHocRun: jest.fn(), +})); + +describe('isRunning handler', () => { + const soClient = jest.fn() as unknown as ISavedObjectsRepository; + const logger = { + error: jest.fn(), + } as unknown as Logger; + beforeEach(() => { + (partiallyUpdateAdHocRun as jest.Mock).mockClear(); + (logger.error as jest.Mock).mockClear(); + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + test('Should resolve if nothing got started', async () => { + (partiallyUpdateAdHocRun as jest.Mock).mockImplementation(() => Promise.resolve('resolve')); + const runHandler = new AdHocTaskRunningHandler(soClient, logger); + const resp = await runHandler.waitFor(); + expect(partiallyUpdateAdHocRun).toHaveBeenCalledTimes(0); + expect(logger.error).toHaveBeenCalledTimes(0); + expect(resp).toBe(undefined); + }); + + test('Should return the promise from partiallyUpdateAdHocRun when the update isRunning has been a success', async () => { + (partiallyUpdateAdHocRun as jest.Mock).mockImplementation(() => Promise.resolve('resolve')); + const runHandler = new AdHocTaskRunningHandler(soClient, logger); + runHandler.start('9876543210', [ + { + runAt: '2024-03-01T01:00:00.000Z', + status: adHocRunStatus.RUNNING, + interval: '1h', + }, + { + runAt: '2024-03-01T02:00:00.000Z', + status: adHocRunStatus.PENDING, + interval: '1h', + }, + ]); + jest.runAllTimers(); + const resp = await runHandler.waitFor(); + + expect(partiallyUpdateAdHocRun).toHaveBeenCalledTimes(1); + expect((partiallyUpdateAdHocRun as jest.Mock).mock.calls[0]).toMatchInlineSnapshot(` + Array [ + [MockFunction], + "9876543210", + Object { + "schedule": Array [ + Object { + "interval": "1h", + "runAt": "2024-03-01T01:00:00.000Z", + "status": "running", + }, + Object { + "interval": "1h", + "runAt": "2024-03-01T02:00:00.000Z", + "status": "pending", + }, + ], + "status": "running", + }, + Object { + "ignore404": true, + "namespace": undefined, + "refresh": false, + }, + ] + `); + expect(logger.error).toHaveBeenCalledTimes(0); + expect(resp).toBe('resolve'); + }); + + test('Should reject when the update isRunning has been a failure', async () => { + (partiallyUpdateAdHocRun as jest.Mock).mockImplementation(() => + Promise.reject(new Error('error')) + ); + const runHandler = new AdHocTaskRunningHandler(soClient, logger); + runHandler.start('9876543210', [ + { + runAt: '2024-03-01T01:00:00.000Z', + status: adHocRunStatus.RUNNING, + interval: '1h', + }, + { + runAt: '2024-03-01T02:00:00.000Z', + status: adHocRunStatus.PENDING, + interval: '1h', + }, + ]); + jest.runAllTimers(); + + await expect(runHandler.waitFor()).rejects.toThrow(); + expect(partiallyUpdateAdHocRun).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_running_handler.ts b/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_running_handler.ts new file mode 100644 index 0000000000000..1ef4279a328aa --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/ad_hoc_task_running_handler.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ISavedObjectsRepository, Logger } from '@kbn/core/server'; +import { adHocRunStatus } from '../../common/constants'; +import { AdHocRunSchedule } from '../data/ad_hoc_run/types'; +import { partiallyUpdateAdHocRun } from './lib'; + +const TIME_TO_WAIT = 2000; + +export class AdHocTaskRunningHandler { + private client: ISavedObjectsRepository; + private logger: Logger; + + private runningTimeoutId?: NodeJS.Timeout; + private runningPromise?: Promise; + + constructor(client: ISavedObjectsRepository, logger: Logger) { + this.client = client; + this.logger = logger; + } + + public start(adHocRunParamsId: string, schedule: AdHocRunSchedule[], namespace?: string) { + this.runningTimeoutId = setTimeout(() => { + this.setRunning(adHocRunParamsId, schedule, namespace); + }, TIME_TO_WAIT); + } + + public stop() { + if (this.runningTimeoutId) { + clearTimeout(this.runningTimeoutId); + } + } + + public async waitFor(): Promise { + this.stop(); + if (this.runningPromise) return this.runningPromise; + else return Promise.resolve(); + } + + private setRunning(adHocRunParamsId: string, schedule: AdHocRunSchedule[], namespace?: string) { + this.runningPromise = partiallyUpdateAdHocRun( + this.client, + adHocRunParamsId, + { status: adHocRunStatus.RUNNING, schedule }, + { + ignore404: true, + namespace, + refresh: false, + } + ); + this.runningPromise + .then(() => { + this.runningPromise = undefined; + }) + .catch((err) => { + this.runningPromise = undefined; + this.logger.error( + `error updating status and schedule attribute for ad hoc run ${adHocRunParamsId} ${err.message}` + ); + }); + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/lib/index.ts b/x-pack/plugins/alerting/server/task_runner/lib/index.ts new file mode 100644 index 0000000000000..ae8aa0aca54ec --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/lib/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { partiallyUpdateAdHocRun } from './partially_update_ad_hoc_run'; +export { processRunResults } from './process_run_result'; diff --git a/x-pack/plugins/alerting/server/task_runner/lib/partially_update_ad_hoc_run.test.ts b/x-pack/plugins/alerting/server/task_runner/lib/partially_update_ad_hoc_run.test.ts new file mode 100644 index 0000000000000..4e6e772693442 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/lib/partially_update_ad_hoc_run.test.ts @@ -0,0 +1,159 @@ +/* + * 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 { + SavedObjectsClientContract, + ISavedObjectsRepository, + SavedObjectsErrorHelpers, +} from '@kbn/core/server'; +import { + PartiallyUpdateableAdHocRunAttributes, + partiallyUpdateAdHocRun, +} from './partially_update_ad_hoc_run'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { adHocRunStatus } from '../../../common/constants'; + +const MockSavedObjectsClientContract = savedObjectsClientMock.create(); +const MockISavedObjectsRepository = + MockSavedObjectsClientContract as unknown as jest.Mocked; + +describe('partiallyUpdateAdHocRun', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + for (const [soClientName, soClient] of Object.entries(getMockSavedObjectClients())) + describe(`using ${soClientName}`, () => { + test('should work with no options', async () => { + soClient.update.mockResolvedValueOnce(MockUpdateValue); + + await partiallyUpdateAdHocRun(soClient, MockAdHocRunId, DefaultAttributes); + expect(soClient.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + MockAdHocRunId, + DefaultAttributes, + {} + ); + }); + + test('should strip unallowed attributes ', async () => { + const attributes = UnallowedAttributes as unknown as PartiallyUpdateableAdHocRunAttributes; + soClient.update.mockResolvedValueOnce(MockUpdateValue); + + await partiallyUpdateAdHocRun(soClient, MockAdHocRunId, attributes); + expect(soClient.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + MockAdHocRunId, + DefaultAttributes, + {} + ); + }); + + test('should work with extraneous attributes ', async () => { + const attributes = ExtraneousAttributes as unknown as PartiallyUpdateableAdHocRunAttributes; + soClient.update.mockResolvedValueOnce(MockUpdateValue); + + await partiallyUpdateAdHocRun(soClient, MockAdHocRunId, attributes); + expect(soClient.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + MockAdHocRunId, + ExtraneousAttributes, + {} + ); + }); + + test('should handle SO errors', async () => { + soClient.update.mockRejectedValueOnce(new Error('wops')); + + await expect( + partiallyUpdateAdHocRun(soClient, MockAdHocRunId, DefaultAttributes) + ).rejects.toThrowError('wops'); + }); + + test('should handle the version option', async () => { + soClient.update.mockResolvedValueOnce(MockUpdateValue); + + await partiallyUpdateAdHocRun(soClient, MockAdHocRunId, DefaultAttributes, { + version: '1.2.3', + }); + expect(soClient.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + MockAdHocRunId, + DefaultAttributes, + { + version: '1.2.3', + } + ); + }); + + test('should handle the ignore404 option', async () => { + const err = SavedObjectsErrorHelpers.createGenericNotFoundError(); + soClient.update.mockRejectedValueOnce(err); + + await partiallyUpdateAdHocRun(soClient, MockAdHocRunId, DefaultAttributes, { + ignore404: true, + }); + expect(soClient.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + MockAdHocRunId, + DefaultAttributes, + {} + ); + }); + + test('should handle the namespace option', async () => { + soClient.update.mockResolvedValueOnce(MockUpdateValue); + + await partiallyUpdateAdHocRun(soClient, MockAdHocRunId, DefaultAttributes, { + namespace: 'bat.cave', + }); + expect(soClient.update).toHaveBeenCalledWith( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + MockAdHocRunId, + DefaultAttributes, + { + namespace: 'bat.cave', + } + ); + }); + }); +}); + +function getMockSavedObjectClients(): Record< + string, + jest.Mocked +> { + return { + SavedObjectsClientContract: MockSavedObjectsClientContract, + // doesn't appear to be a mock for this, but it's basically the same as the above, + // so just cast it to make sure we catch any type errors + ISavedObjectsRepository: MockISavedObjectsRepository, + }; +} + +const DefaultAttributes = { + status: adHocRunStatus.RUNNING, + schedule: [ + { interval: '1h', status: adHocRunStatus.COMPLETE, runAt: '2023-10-19T03:07:40.011Z' }, + { interval: '1h', status: adHocRunStatus.PENDING, runAt: '2023-10-20T03:07:40.011Z' }, + { interval: '1h', status: adHocRunStatus.PENDING, runAt: '2023-10-21T03:07:40.011Z' }, + { interval: '1h', status: adHocRunStatus.PENDING, runAt: '2023-10-22T03:07:40.011Z' }, + ], +}; + +const UnallowedAttributes = { ...DefaultAttributes, spaceId: 'yo' }; +const ExtraneousAttributes = { ...DefaultAttributes, foo: 'bar' }; + +const MockAdHocRunId = 'abc'; + +const MockUpdateValue = { + id: MockAdHocRunId, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributes: DefaultAttributes, + references: [], +}; diff --git a/x-pack/plugins/alerting/server/task_runner/lib/partially_update_ad_hoc_run.ts b/x-pack/plugins/alerting/server/task_runner/lib/partially_update_ad_hoc_run.ts new file mode 100644 index 0000000000000..dad89a829c531 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/lib/partially_update_ad_hoc_run.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SavedObjectsClient, + SavedObjectsErrorHelpers, + SavedObjectsUpdateOptions, +} from '@kbn/core/server'; +import { omit, pick } from 'lodash'; +import { AdHocRunSO } from '../../data/ad_hoc_run/types'; +import { + AdHocRunAttributesNotPartiallyUpdatable, + AdHocRunAttributesToEncrypt, + AdHocRunAttributesIncludedInAAD, + AD_HOC_RUN_SAVED_OBJECT_TYPE, +} from '../../saved_objects'; + +export type PartiallyUpdateableAdHocRunAttributes = Partial< + Omit +>; + +interface PartiallyUpdateAdHocRunSavedObjectOptions { + refresh?: SavedObjectsUpdateOptions['refresh']; + version?: string; + ignore404?: boolean; + namespace?: string; // only should be used with ISavedObjectsRepository +} + +// typed this way so we can send a SavedObjectClient or SavedObjectRepository +type SavedObjectClientForUpdate = Pick; + +export async function partiallyUpdateAdHocRun( + savedObjectsClient: SavedObjectClientForUpdate, + id: string, + attributes: PartiallyUpdateableAdHocRunAttributes, + options: PartiallyUpdateAdHocRunSavedObjectOptions = {} +): Promise { + // ensure we only have the valid attributes that are not encrypted and are excluded from AAD + const attributeUpdates = omit(attributes, [ + ...AdHocRunAttributesToEncrypt, + ...AdHocRunAttributesIncludedInAAD, + ]); + const updateOptions: SavedObjectsUpdateOptions = pick( + options, + 'namespace', + 'version', + 'refresh' + ); + + try { + await savedObjectsClient.update( + AD_HOC_RUN_SAVED_OBJECT_TYPE, + id, + attributeUpdates, + updateOptions + ); + } catch (err) { + if (options?.ignore404 && SavedObjectsErrorHelpers.isNotFoundError(err)) { + return; + } + throw err; + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/lib/process_run_result.test.ts b/x-pack/plugins/alerting/server/task_runner/lib/process_run_result.test.ts new file mode 100644 index 0000000000000..e30865028867a --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/lib/process_run_result.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { processRunResults } from './process_run_result'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { ruleResultServiceMock } from '../../monitoring/rule_result_service.mock'; +import { asErr, asOk } from '../../lib/result_type'; +import { ActionsCompletion } from '@kbn/alerting-state-types'; + +const logger = loggingSystemMock.create().get(); +const ruleResultService = ruleResultServiceMock.create(); + +const executionMetrics = { + numSearches: 1, + esSearchDurationMs: 10, + totalSearchDurationMs: 20, + numberOfTriggeredActions: 32, + numberOfGeneratedActions: 11, + numberOfActiveAlerts: 2, + numberOfNewAlerts: 3, + numberOfRecoveredAlerts: 13, + numberOfDelayedAlerts: 7, + hasReachedAlertLimit: false, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + hasReachedQueuedActionsLimit: false, +}; + +describe('processRunResults', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should process results as expected when results are successful', () => { + ruleResultService.getLastRunResults.mockReturnValue({ + errors: [], + warnings: [], + message: 'i am a message', + }); + expect( + processRunResults({ + result: ruleResultService, + runDate: new Date('2024-03-13T00:00:00.000Z'), + runResultWithMetrics: asOk({ + alertInstances: { a: {} }, + metrics: executionMetrics, + }), + }) + ).toEqual({ + executionMetrics, + executionStatus: { + lastExecutionDate: new Date('2024-03-13T00:00:00.000Z'), + status: 'active', + }, + lastRun: { + alertsCount: { + active: executionMetrics.numberOfActiveAlerts, + ignored: 0, + new: executionMetrics.numberOfNewAlerts, + recovered: executionMetrics.numberOfRecoveredAlerts, + }, + outcome: 'succeeded', + outcomeMsg: null, + outcomeOrder: 0, + warning: null, + }, + outcome: 'success', + }); + }); + + test('should log results when logger is provided', () => { + ruleResultService.getLastRunResults.mockReturnValue({ + errors: [], + warnings: [], + message: 'i am a message', + }); + expect( + processRunResults({ + logger, + logPrefix: `myRuleType:1`, + result: ruleResultService, + runDate: new Date('2024-03-13T00:00:00.000Z'), + runResultWithMetrics: asOk({ + alertInstances: { a: {} }, + metrics: executionMetrics, + }), + }) + ).toEqual({ + executionMetrics, + executionStatus: { + lastExecutionDate: new Date('2024-03-13T00:00:00.000Z'), + status: 'active', + }, + lastRun: { + alertsCount: { + active: executionMetrics.numberOfActiveAlerts, + ignored: 0, + new: executionMetrics.numberOfNewAlerts, + recovered: executionMetrics.numberOfRecoveredAlerts, + }, + outcome: 'succeeded', + outcomeMsg: null, + outcomeOrder: 0, + warning: null, + }, + outcome: 'success', + }); + expect(logger.debug).toHaveBeenCalledTimes(3); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + 'deprecated ruleRunStatus for myRuleType:1: {"lastExecutionDate":"2024-03-13T00:00:00.000Z","status":"active"}' + ); + expect(logger.debug).toHaveBeenNthCalledWith( + 2, + 'ruleRunStatus for myRuleType:1: {"outcome":"succeeded","outcomeOrder":0,"outcomeMsg":null,"warning":null,"alertsCount":{"active":2,"new":3,"recovered":13,"ignored":0}}' + ); + expect(logger.debug).toHaveBeenNthCalledWith( + 3, + 'ruleRunMetrics for myRuleType:1: {"numSearches":1,"esSearchDurationMs":10,"totalSearchDurationMs":20,"numberOfTriggeredActions":32,"numberOfGeneratedActions":11,"numberOfActiveAlerts":2,"numberOfNewAlerts":3,"numberOfRecoveredAlerts":13,"numberOfDelayedAlerts":7,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete","hasReachedQueuedActionsLimit":false}' + ); + }); + + test('should process results as expected when results are failure', () => { + ruleResultService.getLastRunResults.mockReturnValueOnce({ + errors: ['error error'], + warnings: ['warning'], + message: 'i am an error message', + }); + expect( + processRunResults({ + logger, + logPrefix: `myRuleType:1`, + result: ruleResultService, + runDate: new Date('2024-03-13T00:00:00.000Z'), + runResultWithMetrics: asErr(new Error('fail fail')), + }) + ).toEqual({ + executionMetrics: null, + executionStatus: { + error: { + message: 'fail fail', + reason: 'unknown', + }, + status: 'error', + lastExecutionDate: new Date('2024-03-13T00:00:00.000Z'), + }, + lastRun: { + alertsCount: {}, + outcome: 'failed', + outcomeMsg: ['fail fail'], + outcomeOrder: 20, + warning: 'unknown', + }, + outcome: 'failure', + }); + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + 'deprecated ruleRunStatus for myRuleType:1: {"lastExecutionDate":"2024-03-13T00:00:00.000Z","status":"error","error":{"reason":"unknown","message":"fail fail"}}' + ); + expect(logger.debug).toHaveBeenNthCalledWith( + 2, + 'ruleRunStatus for myRuleType:1: {"outcome":"failed","outcomeOrder":20,"warning":"unknown","outcomeMsg":["fail fail"],"alertsCount":{}}' + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/lib/process_run_result.ts b/x-pack/plugins/alerting/server/task_runner/lib/process_run_result.ts new file mode 100644 index 0000000000000..d419ffeb72a3a --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/lib/process_run_result.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '@kbn/core/server'; +import { Outcome } from 'elastic-apm-node'; +import { RuleExecutionStatus, RuleLastRun } from '../../../common'; +import { ElasticsearchError } from '../../lib'; +import { ILastRun, lastRunFromError, lastRunFromState } from '../../lib/last_run_status'; +import { map, Result } from '../../lib/result_type'; +import { + executionStatusFromError, + executionStatusFromState, + IExecutionStatusAndMetrics, +} from '../../lib/rule_execution_status'; +import { RuleRunMetrics } from '../../lib/rule_run_metrics_store'; +import { RuleResultService } from '../../monitoring/rule_result_service'; +import { RuleTaskStateAndMetrics } from '../types'; + +interface ProcessRuleRunOpts { + logger?: Logger; + logPrefix?: string; + result: RuleResultService; + runDate: Date; + runResultWithMetrics: Result; +} + +interface ProcessRuleRunResult { + executionStatus: RuleExecutionStatus; + executionMetrics: RuleRunMetrics | null; + lastRun: RuleLastRun; + outcome: Outcome; +} + +export function processRunResults({ + logger, + logPrefix, + result, + runDate, + runResultWithMetrics, +}: ProcessRuleRunOpts): ProcessRuleRunResult { + // Getting executionStatus for backwards compatibility + const { status: executionStatus } = map< + RuleTaskStateAndMetrics, + ElasticsearchError, + IExecutionStatusAndMetrics + >( + runResultWithMetrics, + (ruleRunStateWithMetrics) => + executionStatusFromState({ + stateWithMetrics: ruleRunStateWithMetrics, + lastExecutionDate: runDate, + ruleResultService: result, + }), + (err: ElasticsearchError) => executionStatusFromError(err, runDate) + ); + + // New consolidated statuses for lastRun + const { lastRun, metrics: executionMetrics } = map< + RuleTaskStateAndMetrics, + ElasticsearchError, + ILastRun + >( + runResultWithMetrics, + (ruleRunStateWithMetrics) => lastRunFromState(ruleRunStateWithMetrics, result), + (err: ElasticsearchError) => lastRunFromError(err) + ); + + if (logger) { + logger.debug(`deprecated ruleRunStatus for ${logPrefix}: ${JSON.stringify(executionStatus)}`); + logger.debug(`ruleRunStatus for ${logPrefix}: ${JSON.stringify(lastRun)}`); + if (executionMetrics) { + logger.debug(`ruleRunMetrics for ${logPrefix}: ${JSON.stringify(executionMetrics)}`); + } + } + + let outcome: Outcome = 'success'; + if (executionStatus.status === 'ok' || executionStatus.status === 'active') { + outcome = 'success'; + } else if (executionStatus.status === 'error' || executionStatus.status === 'unknown') { + outcome = 'failure'; + } else if (lastRun.outcome === 'succeeded') { + outcome = 'success'; + } else if (lastRun.outcome === 'failed') { + outcome = 'failure'; + } + + return { executionStatus, executionMetrics, lastRun, outcome }; +} diff --git a/x-pack/plugins/alerting/server/task_runner/running_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/rule_running_handler.test.ts similarity index 89% rename from x-pack/plugins/alerting/server/task_runner/running_handler.test.ts rename to x-pack/plugins/alerting/server/task_runner/rule_running_handler.test.ts index 3ebb122b3a19a..08c1e0c7292ee 100644 --- a/x-pack/plugins/alerting/server/task_runner/running_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_running_handler.test.ts @@ -7,7 +7,7 @@ import { ISavedObjectsRepository, Logger } from '@kbn/core/server'; import { partiallyUpdateRule } from '../saved_objects/partially_update_rule'; -import { RunningHandler } from './running_handler'; +import { RuleRunningHandler } from './rule_running_handler'; jest.mock('../saved_objects/partially_update_rule', () => ({ partiallyUpdateRule: jest.fn(), @@ -30,7 +30,7 @@ describe('isRunning handler', () => { test('Should resolve if nothing got started', async () => { (partiallyUpdateRule as jest.Mock).mockImplementation(() => Promise.resolve('resolve')); - const runHandler = new RunningHandler(soClient, logger, ruleTypeId); + const runHandler = new RuleRunningHandler(soClient, logger, ruleTypeId); const resp = await runHandler.waitFor(); expect(partiallyUpdateRule).toHaveBeenCalledTimes(0); expect(logger.error).toHaveBeenCalledTimes(0); @@ -39,7 +39,7 @@ describe('isRunning handler', () => { test('Should return the promise from partiallyUpdateRule when the update isRunning has been a success', async () => { (partiallyUpdateRule as jest.Mock).mockImplementation(() => Promise.resolve('resolve')); - const runHandler = new RunningHandler(soClient, logger, ruleTypeId); + const runHandler = new RuleRunningHandler(soClient, logger, ruleTypeId); runHandler.start('9876543210'); jest.runAllTimers(); const resp = await runHandler.waitFor(); @@ -65,7 +65,7 @@ describe('isRunning handler', () => { test('Should reject when the update isRunning has been a failure', async () => { (partiallyUpdateRule as jest.Mock).mockImplementation(() => Promise.reject(new Error('error'))); - const runHandler = new RunningHandler(soClient, logger, ruleTypeId); + const runHandler = new RuleRunningHandler(soClient, logger, ruleTypeId); runHandler.start('9876543210'); jest.runAllTimers(); diff --git a/x-pack/plugins/alerting/server/task_runner/running_handler.ts b/x-pack/plugins/alerting/server/task_runner/rule_running_handler.ts similarity index 98% rename from x-pack/plugins/alerting/server/task_runner/running_handler.ts rename to x-pack/plugins/alerting/server/task_runner/rule_running_handler.ts index 1602e9421ecef..3794937343f26 100644 --- a/x-pack/plugins/alerting/server/task_runner/running_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_running_handler.ts @@ -10,7 +10,7 @@ import { partiallyUpdateRule } from '../saved_objects/partially_update_rule'; const TIME_TO_WAIT = 2000; -export class RunningHandler { +export class RuleRunningHandler { private client: ISavedObjectsRepository; private logger: Logger; private ruleTypeId: string; diff --git a/x-pack/plugins/alerting/server/task_runner/rule_type_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/rule_type_runner.test.ts index 9c32710714467..e5779dd6eacad 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_type_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_type_runner.test.ts @@ -7,23 +7,12 @@ import { savedObjectsClientMock, uiSettingsServiceMock } from '@kbn/core/server/mocks'; import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; -import { - DATE_1970, - mockedRule, - mockTaskInstance, - RULE_ID, - RULE_NAME, - RULE_TYPE_ID, -} from './fixtures'; +import { DATE_1970, mockTaskInstance, RULE_ID, RULE_NAME, RULE_TYPE_ID } from './fixtures'; import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; import { ruleRunMetricsStoreMock } from '../lib/rule_run_metrics_store.mock'; -import { RuleTypeRunner } from './rule_type_runner'; +import { RuleTypeRunner, RuleData } from './rule_type_runner'; import { TaskRunnerTimer } from './task_runner_timer'; -import { - DEFAULT_FLAPPING_SETTINGS, - DEFAULT_QUERY_DELAY_SETTINGS, - RecoveredActionGroup, -} from '../types'; +import { DEFAULT_FLAPPING_SETTINGS, RecoveredActionGroup } from '../types'; import { TaskRunnerContext } from './types'; import { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks'; import { SharePluginStart } from '@kbn/share-plugin/server'; @@ -34,6 +23,7 @@ import { publicRuleResultServiceMock } from '../monitoring/rule_result_service.m import { wrappedScopedClusterClientMock } from '../lib/wrap_scoped_cluster_client.mock'; import { wrappedSearchSourceClientMock } from '../lib/wrap_search_source_client.mock'; import { NormalizedRuleType } from '../rule_type_registry'; +import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server'; const alertingEventLogger = alertingEventLoggerMock.create(); const alertsClient = alertsClientMock.create(); @@ -74,6 +64,64 @@ const ruleType: jest.Mocked< validLegacyConsumers: [], }; +const mockedRule: RuleData> = { + alertTypeId: ruleType.id, + consumer: 'bar', + schedule: { interval: '10s' }, + throttle: null, + notifyWhen: 'onActiveAlert', + name: RULE_NAME, + tags: ['rule-', '-tags'], + createdBy: 'rule-creator', + updatedBy: 'rule-updater', + createdAt: new Date('2019-02-12T21:01:22.479Z'), + updatedAt: new Date('2019-02-12T21:01:22.479Z'), + enabled: true, + actions: [ + { + group: 'default', + actionTypeId: 'action', + params: { + foo: true, + }, + uuid: '111-111', + id: '1', + }, + { + group: RecoveredActionGroup.id, + actionTypeId: 'action', + params: { + isResolved: true, + }, + uuid: '222-222', + id: '2', + }, + ], + muteAll: false, + revision: 0, +}; + +const mockedRuleParams = { bar: true }; + +const mockedTaskInstance: ConcreteTaskInstance = { + id: '', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(DATE_1970), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: {}, + taskType: 'backfill', + timeoutOverride: '3m', + params: { + adHocRunParamsId: 'abc', + spaceId: 'default', + }, + ownerId: null, +}; + describe('RuleTypeRunner', () => { let ruleTypeRunner: RuleTypeRunner<{}, {}, { foo: string }, {}, {}, 'default', 'recovered', {}>; let context: TaskRunnerContext; @@ -95,9 +143,9 @@ describe('RuleTypeRunner', () => { {} >({ context, - timer, logger, - ruleType, + task: mockedTaskInstance, + timer, }); }); @@ -109,7 +157,7 @@ describe('RuleTypeRunner', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, + queryDelaySec: 0, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -127,9 +175,10 @@ describe('RuleTypeRunner', () => { wrappedSearchSourceClient, }, rule: mockedRule, + ruleType, startedAt: new Date(DATE_1970), state: mockTaskInstance().state, - validatedParams: mockedRule.params, + validatedParams: mockedRuleParams, }); expect(ruleType.executor).toHaveBeenCalledWith({ @@ -148,9 +197,10 @@ describe('RuleTypeRunner', () => { shouldWriteAlerts: expect.any(Function), uiSettingsClient, }, - params: mockedRule.params, + params: mockedRuleParams, state: mockTaskInstance().state, startedAt: new Date(DATE_1970), + startedAtOverridden: false, previousStartedAt: null, spaceId: 'default', rule: { @@ -197,6 +247,175 @@ describe('RuleTypeRunner', () => { ruleRunMetricsStore, }); expect(alertsClient.persistAlerts).toHaveBeenCalledWith([]); + expect(alertingEventLogger.setMaintenanceWindowIds).not.toHaveBeenCalled(); + expect(alertsClient.logAlerts).toHaveBeenCalledWith({ + eventLogger: alertingEventLogger, + ruleRunMetricsStore, + shouldLogAlerts: true, + }); + }); + + test('should identify when startedAt passed to executor does not equal task startedAt', async () => { + const differentStartedAt = new Date(); + ruleType.executor.mockResolvedValueOnce({ state: { foo: 'bar' } }); + + const { state, error, stackTrace } = await ruleTypeRunner.run({ + context: { + alertingEventLogger, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + queryDelaySec: 0, + ruleId: RULE_ID, + ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, + ruleRunMetricsStore, + spaceId: 'default', + }, + alertsClient, + executionId: 'abc', + executorServices: { + dataViews, + ruleMonitoringService: publicRuleMonitoringService, + ruleResultService: publicRuleResultService, + savedObjectsClient, + uiSettingsClient, + wrappedScopedClusterClient, + wrappedSearchSourceClient, + }, + rule: mockedRule, + ruleType, + startedAt: differentStartedAt, + state: mockTaskInstance().state, + validatedParams: mockedRuleParams, + }); + + expect(ruleType.executor).toHaveBeenCalledWith({ + executionId: 'abc', + services: { + alertFactory: alertsClient.factory(), + alertsClient: alertsClient.client(), + dataViews, + ruleMonitoringService: publicRuleMonitoringService, + ruleResultService: publicRuleResultService, + savedObjectsClient, + scopedClusterClient: wrappedScopedClusterClient.client(), + searchSourceClient: wrappedSearchSourceClient.searchSourceClient, + share: {}, + shouldStopExecution: expect.any(Function), + shouldWriteAlerts: expect.any(Function), + uiSettingsClient, + }, + params: mockedRuleParams, + state: mockTaskInstance().state, + startedAt: differentStartedAt, + startedAtOverridden: true, + previousStartedAt: null, + spaceId: 'default', + rule: { + id: RULE_ID, + name: mockedRule.name, + tags: mockedRule.tags, + consumer: mockedRule.consumer, + producer: ruleType.producer, + revision: mockedRule.revision, + ruleTypeId: mockedRule.alertTypeId, + ruleTypeName: ruleType.name, + enabled: mockedRule.enabled, + schedule: mockedRule.schedule, + actions: mockedRule.actions, + createdBy: mockedRule.createdBy, + updatedBy: mockedRule.updatedBy, + createdAt: mockedRule.createdAt, + updatedAt: mockedRule.updatedAt, + throttle: mockedRule.throttle, + notifyWhen: mockedRule.notifyWhen, + muteAll: mockedRule.muteAll, + snoozeSchedule: mockedRule.snoozeSchedule, + alertDelay: mockedRule.alertDelay, + }, + logger, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + getTimeRange: expect.any(Function), + }); + + expect(state).toEqual({ foo: 'bar' }); + expect(error).toBeUndefined(); + expect(stackTrace).toBeUndefined(); + expect(alertsClient.hasReachedAlertLimit).toHaveBeenCalled(); + expect(alertsClient.checkLimitUsage).toHaveBeenCalled(); + expect(alertingEventLogger.setExecutionSucceeded).toHaveBeenCalledWith( + `rule executed: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'` + ); + expect(ruleRunMetricsStore.setSearchMetrics).toHaveBeenCalled(); + expect(alertsClient.processAlerts).toHaveBeenCalledWith({ + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + notifyOnActionGroupChange: false, + maintenanceWindowIds: [], + alertDelay: 0, + ruleRunMetricsStore, + }); + expect(alertsClient.persistAlerts).toHaveBeenCalledWith([]); + expect(alertingEventLogger.setMaintenanceWindowIds).not.toHaveBeenCalled(); + expect(alertsClient.logAlerts).toHaveBeenCalledWith({ + eventLogger: alertingEventLogger, + ruleRunMetricsStore, + shouldLogAlerts: true, + }); + }); + + test('should update maintenance window ids in event logger if alerts are affected', async () => { + alertsClient.persistAlerts.mockResolvedValueOnce({ + alertId: ['1'], + maintenanceWindowIds: ['abc'], + }); + ruleType.executor.mockResolvedValueOnce({ state: { foo: 'bar' } }); + + const { state, error, stackTrace } = await ruleTypeRunner.run({ + context: { + alertingEventLogger, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + queryDelaySec: 0, + ruleId: RULE_ID, + ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, + ruleRunMetricsStore, + spaceId: 'default', + }, + alertsClient, + executionId: 'abc', + executorServices: { + dataViews, + ruleMonitoringService: publicRuleMonitoringService, + ruleResultService: publicRuleResultService, + savedObjectsClient, + uiSettingsClient, + wrappedScopedClusterClient, + wrappedSearchSourceClient, + }, + rule: mockedRule, + ruleType, + startedAt: new Date(DATE_1970), + state: mockTaskInstance().state, + validatedParams: mockedRuleParams, + }); + + expect(ruleType.executor).toHaveBeenCalled(); + + expect(state).toEqual({ foo: 'bar' }); + expect(error).toBeUndefined(); + expect(stackTrace).toBeUndefined(); + expect(alertsClient.hasReachedAlertLimit).toHaveBeenCalled(); + expect(alertsClient.checkLimitUsage).toHaveBeenCalled(); + expect(alertingEventLogger.setExecutionSucceeded).toHaveBeenCalledWith( + `rule executed: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'` + ); + expect(ruleRunMetricsStore.setSearchMetrics).toHaveBeenCalled(); + expect(alertsClient.processAlerts).toHaveBeenCalledWith({ + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + notifyOnActionGroupChange: false, + maintenanceWindowIds: [], + alertDelay: 0, + ruleRunMetricsStore, + }); + expect(alertsClient.persistAlerts).toHaveBeenCalledWith([]); + expect(alertingEventLogger.setMaintenanceWindowIds).toHaveBeenCalledWith(['abc']); expect(alertsClient.logAlerts).toHaveBeenCalledWith({ eventLogger: alertingEventLogger, ruleRunMetricsStore, @@ -215,7 +434,7 @@ describe('RuleTypeRunner', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, + queryDelaySec: 0, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -233,9 +452,10 @@ describe('RuleTypeRunner', () => { wrappedSearchSourceClient, }, rule: mockedRule, + ruleType, startedAt: new Date(DATE_1970), state: mockTaskInstance().state, - validatedParams: mockedRule.params, + validatedParams: mockedRuleParams, }); expect(ruleType.executor).toHaveBeenCalledWith({ @@ -254,9 +474,10 @@ describe('RuleTypeRunner', () => { shouldWriteAlerts: expect.any(Function), uiSettingsClient, }, - params: mockedRule.params, + params: mockedRuleParams, state: mockTaskInstance().state, startedAt: new Date(DATE_1970), + startedAtOverridden: false, previousStartedAt: null, spaceId: 'default', rule: { @@ -312,7 +533,7 @@ describe('RuleTypeRunner', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, + queryDelaySec: 0, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -330,9 +551,10 @@ describe('RuleTypeRunner', () => { wrappedSearchSourceClient, }, rule: mockedRule, + ruleType, startedAt: new Date(DATE_1970), state: mockTaskInstance().state, - validatedParams: mockedRule.params, + validatedParams: mockedRuleParams, }); expect(ruleType.executor).toHaveBeenCalledWith({ @@ -351,9 +573,10 @@ describe('RuleTypeRunner', () => { shouldWriteAlerts: expect.any(Function), uiSettingsClient, }, - params: mockedRule.params, + params: mockedRuleParams, state: mockTaskInstance().state, startedAt: new Date(DATE_1970), + startedAtOverridden: false, previousStartedAt: null, spaceId: 'default', rule: { @@ -407,7 +630,7 @@ describe('RuleTypeRunner', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, + queryDelaySec: 0, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -425,9 +648,10 @@ describe('RuleTypeRunner', () => { wrappedSearchSourceClient, }, rule: mockedRule, + ruleType, startedAt: new Date(DATE_1970), state: mockTaskInstance().state, - validatedParams: mockedRule.params, + validatedParams: mockedRuleParams, }); expect(ruleType.executor).toHaveBeenCalledWith({ @@ -446,9 +670,10 @@ describe('RuleTypeRunner', () => { shouldWriteAlerts: expect.any(Function), uiSettingsClient, }, - params: mockedRule.params, + params: mockedRuleParams, state: mockTaskInstance().state, startedAt: new Date(DATE_1970), + startedAtOverridden: false, previousStartedAt: null, spaceId: 'default', rule: { @@ -518,7 +743,7 @@ describe('RuleTypeRunner', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, + queryDelaySec: 0, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -536,9 +761,10 @@ describe('RuleTypeRunner', () => { wrappedSearchSourceClient, }, rule: mockedRule, + ruleType, startedAt: new Date(DATE_1970), state: mockTaskInstance().state, - validatedParams: mockedRule.params, + validatedParams: mockedRuleParams, }); expect(ruleType.executor).toHaveBeenCalledWith({ @@ -557,9 +783,10 @@ describe('RuleTypeRunner', () => { shouldWriteAlerts: expect.any(Function), uiSettingsClient, }, - params: mockedRule.params, + params: mockedRuleParams, state: mockTaskInstance().state, startedAt: new Date(DATE_1970), + startedAtOverridden: false, previousStartedAt: null, spaceId: 'default', rule: { @@ -629,7 +856,7 @@ describe('RuleTypeRunner', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, + queryDelaySec: 0, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -647,9 +874,10 @@ describe('RuleTypeRunner', () => { wrappedSearchSourceClient, }, rule: mockedRule, + ruleType, startedAt: new Date(DATE_1970), state: mockTaskInstance().state, - validatedParams: mockedRule.params, + validatedParams: mockedRuleParams, }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"process alerts failed"`); @@ -669,9 +897,10 @@ describe('RuleTypeRunner', () => { shouldWriteAlerts: expect.any(Function), uiSettingsClient, }, - params: mockedRule.params, + params: mockedRuleParams, state: mockTaskInstance().state, startedAt: new Date(DATE_1970), + startedAtOverridden: false, previousStartedAt: null, spaceId: 'default', rule: { @@ -730,7 +959,7 @@ describe('RuleTypeRunner', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, + queryDelaySec: 0, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -748,9 +977,10 @@ describe('RuleTypeRunner', () => { wrappedSearchSourceClient, }, rule: mockedRule, + ruleType, startedAt: new Date(DATE_1970), state: mockTaskInstance().state, - validatedParams: mockedRule.params, + validatedParams: mockedRuleParams, }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"persist alerts failed"`); @@ -770,9 +1000,10 @@ describe('RuleTypeRunner', () => { shouldWriteAlerts: expect.any(Function), uiSettingsClient, }, - params: mockedRule.params, + params: mockedRuleParams, state: mockTaskInstance().state, startedAt: new Date(DATE_1970), + startedAtOverridden: false, previousStartedAt: null, spaceId: 'default', rule: { @@ -831,7 +1062,7 @@ describe('RuleTypeRunner', () => { context: { alertingEventLogger, flappingSettings: DEFAULT_FLAPPING_SETTINGS, - queryDelaySettings: DEFAULT_QUERY_DELAY_SETTINGS, + queryDelaySec: 0, ruleId: RULE_ID, ruleLogPrefix: `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`, ruleRunMetricsStore, @@ -849,9 +1080,10 @@ describe('RuleTypeRunner', () => { wrappedSearchSourceClient, }, rule: mockedRule, + ruleType, startedAt: new Date(DATE_1970), state: mockTaskInstance().state, - validatedParams: mockedRule.params, + validatedParams: mockedRuleParams, }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"log alerts failed"`); @@ -871,9 +1103,10 @@ describe('RuleTypeRunner', () => { shouldWriteAlerts: expect.any(Function), uiSettingsClient, }, - params: mockedRule.params, + params: mockedRuleParams, state: mockTaskInstance().state, startedAt: new Date(DATE_1970), + startedAtOverridden: false, previousStartedAt: null, spaceId: 'default', rule: { diff --git a/x-pack/plugins/alerting/server/task_runner/rule_type_runner.ts b/x-pack/plugins/alerting/server/task_runner/rule_type_runner.ts index b105c412d07b1..da29b87714683 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_type_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/rule_type_runner.ts @@ -8,7 +8,11 @@ import { AlertInstanceContext, AlertInstanceState, RuleTaskState } from '@kbn/alerting-state-types'; import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; import { Logger } from '@kbn/core/server'; -import { createTaskRunError, TaskErrorSource } from '@kbn/task-manager-plugin/server'; +import { + ConcreteTaskInstance, + createTaskRunError, + TaskErrorSource, +} from '@kbn/task-manager-plugin/server'; import { some } from 'lodash'; import { IAlertsClient } from '../alerts_client/types'; import { MaintenanceWindow } from '../application/maintenance_window/types'; @@ -16,6 +20,7 @@ import { ErrorWithReason } from '../lib'; import { getTimeRange } from '../lib/get_time_range'; import { NormalizedRuleType } from '../rule_type_registry'; import { + DEFAULT_FLAPPING_SETTINGS, RuleAlertData, RuleExecutionStatusErrorReasons, RuleNotifyWhen, @@ -24,9 +29,8 @@ import { SanitizedRule, } from '../types'; import { ExecutorServices } from './get_executor_services'; -import { StackTraceLog } from './task_runner'; import { TaskRunnerTimer, TaskRunnerTimerSpan } from './task_runner_timer'; -import { RuleTypeRunnerContext, TaskRunnerContext } from './types'; +import { RuleRunnerErrorStackTraceLog, RuleTypeRunnerContext, TaskRunnerContext } from './types'; interface ConstructorOpts< Params extends RuleTypeParams, @@ -39,22 +43,36 @@ interface ConstructorOpts< AlertData extends RuleAlertData > { context: TaskRunnerContext; - timer: TaskRunnerTimer; logger: Logger; - ruleType: NormalizedRuleType< - Params, - ExtractedParams, - RuleState, - State, - Context, - ActionGroupIds, - RecoveryActionGroupId, - AlertData - >; + task: ConcreteTaskInstance; + timer: TaskRunnerTimer; } +export type RuleData = Pick< + SanitizedRule, + | 'alertTypeId' + | 'consumer' + | 'schedule' + | 'throttle' + | 'notifyWhen' + | 'name' + | 'tags' + | 'createdBy' + | 'updatedBy' + | 'createdAt' + | 'updatedAt' + | 'enabled' + | 'actions' + | 'muteAll' + | 'revision' + | 'snoozeSchedule' + | 'alertDelay' +>; + interface RunOpts< Params extends RuleTypeParams, + ExtractedParams extends RuleTypeParams, + RuleState extends RuleTypeState, State extends AlertInstanceState, Context extends AlertInstanceContext, ActionGroupIds extends string, @@ -72,8 +90,18 @@ interface RunOpts< }; maintenanceWindows?: MaintenanceWindow[]; maintenanceWindowsWithoutScopedQueryIds?: string[]; - rule: SanitizedRule; - startedAt: Date | null; + rule: RuleData; + ruleType: NormalizedRuleType< + Params, + ExtractedParams, + RuleState, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId, + AlertData + >; + startedAt: Date; state: RuleTaskState; validatedParams: Params; } @@ -81,7 +109,7 @@ interface RunOpts< interface RunResult { state: RuleTypeState | undefined; error?: Error; - stackTrace?: StackTraceLog | null; + stackTrace?: RuleRunnerErrorStackTraceLog | null; } export class RuleTypeRunner< @@ -121,11 +149,14 @@ export class RuleTypeRunner< maintenanceWindows = [], maintenanceWindowsWithoutScopedQueryIds = [], rule, + ruleType, startedAt, state, validatedParams, }: RunOpts< Params, + ExtractedParams, + RuleState, State, Context, ActionGroupIds, @@ -154,6 +185,9 @@ export class RuleTypeRunner< const { alertTypeState: ruleTypeState = {}, previousStartedAt } = state; + const startedAtOverridden = + this.options.task.startedAt?.toISOString() !== startedAt.toISOString(); + const { updatedRuleTypeState, error, stackTrace } = await this.options.timer.runWithTimer( TaskRunnerTimerSpan.RuleTypeRun, async () => { @@ -179,7 +213,7 @@ export class RuleTypeRunner< }] namespace`, }; executorResult = await this.options.context.executionContext.withContext(ctx, () => - this.options.ruleType.executor({ + ruleType.executor({ executionId, services: { alertFactory: alertsClient.factory(), @@ -192,12 +226,14 @@ export class RuleTypeRunner< searchSourceClient: executorServices.wrappedSearchSourceClient.searchSourceClient, share: this.options.context.share, shouldStopExecution: () => this.cancelled, - shouldWriteAlerts: () => this.shouldLogAndScheduleActionsForAlerts(), + shouldWriteAlerts: () => + this.shouldLogAndScheduleActionsForAlerts(ruleType.cancelAlertsOnRuleTimeout), uiSettingsClient: executorServices.uiSettingsClient, }, params: validatedParams, state: ruleTypeState as RuleState, - startedAt: startedAt!, + startedAtOverridden, + startedAt, previousStartedAt: previousStartedAt ? new Date(previousStartedAt) : null, spaceId: context.spaceId, namespace: context.namespace, @@ -206,10 +242,10 @@ export class RuleTypeRunner< name, tags, consumer, - producer: this.options.ruleType.producer, + producer: ruleType.producer, revision, ruleTypeId, - ruleTypeName: this.options.ruleType.name, + ruleTypeName: ruleType.name, enabled, schedule, actions, @@ -224,13 +260,18 @@ export class RuleTypeRunner< alertDelay, }, logger: this.options.logger, - flappingSettings: context.flappingSettings, + flappingSettings: context.flappingSettings ?? DEFAULT_FLAPPING_SETTINGS, // passed in so the rule registry knows about maintenance windows ...(maintenanceWindowsWithoutScopedQueryIds.length ? { maintenanceWindowIds: maintenanceWindowsWithoutScopedQueryIds } : {}), getTimeRange: (timeWindow) => - getTimeRange(this.options.logger, context.queryDelaySettings, timeWindow), + getTimeRange({ + logger: this.options.logger, + window: timeWindow, + ...(context.queryDelaySec ? { queryDelay: context.queryDelaySec } : {}), + ...(startedAtOverridden ? { forceNow: startedAt } : {}), + }), }) ); // Rule type execution has successfully completed @@ -279,7 +320,7 @@ export class RuleTypeRunner< await this.options.timer.runWithTimer(TaskRunnerTimerSpan.ProcessAlerts, async () => { alertsClient.processAlerts({ - flappingSettings: context.flappingSettings, + flappingSettings: context.flappingSettings ?? DEFAULT_FLAPPING_SETTINGS, notifyOnActionGroupChange: notifyWhen === RuleNotifyWhen.CHANGE || some(actions, (action) => action.frequency?.notifyWhen === RuleNotifyWhen.CHANGE), @@ -296,7 +337,10 @@ export class RuleTypeRunner< // Set the event log MW ids again, this time including the ids that matched alerts with // scoped query - if (updateAlertsMaintenanceWindowResult?.maintenanceWindowIds) { + if ( + updateAlertsMaintenanceWindowResult?.maintenanceWindowIds && + updateAlertsMaintenanceWindowResult?.maintenanceWindowIds.length > 0 + ) { context.alertingEventLogger.setMaintenanceWindowIds( updateAlertsMaintenanceWindowResult.maintenanceWindowIds ); @@ -306,22 +350,21 @@ export class RuleTypeRunner< alertsClient.logAlerts({ eventLogger: context.alertingEventLogger, ruleRunMetricsStore: context.ruleRunMetricsStore, - shouldLogAlerts: this.shouldLogAndScheduleActionsForAlerts(), + shouldLogAlerts: this.shouldLogAndScheduleActionsForAlerts( + ruleType.cancelAlertsOnRuleTimeout + ), }); return { state: updatedRuleTypeState }; } - private shouldLogAndScheduleActionsForAlerts() { + private shouldLogAndScheduleActionsForAlerts(ruleTypeShouldCancel?: boolean) { // if execution hasn't been cancelled, return true if (!this.cancelled) { return true; } // if execution has been cancelled, return true if EITHER alerting config or rule type indicate to proceed with scheduling actions - return ( - !this.options.context.cancelAlertsOnRuleTimeout || - !this.options.ruleType.cancelAlertsOnRuleTimeout - ); + return !this.options.context.cancelAlertsOnRuleTimeout || !ruleTypeShouldCancel; } } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index c8b5cbb999743..55aa3247aaa7a 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -74,7 +74,7 @@ import { translations } from '../constants/translations'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; import { AlertingEventLogger, - RuleContextOpts, + ContextOpts, } from '../lib/alerting_event_logger/alerting_event_logger'; import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; import { SharePluginStart } from '@kbn/share-plugin/server'; @@ -91,6 +91,8 @@ import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; import { getErrorSource } from '@kbn/task-manager-plugin/server/task_running'; import { RuleResultService } from '../monitoring/rule_result_service'; import { ruleResultServiceMock } from '../monitoring/rule_result_service.mock'; +import { backfillClientMock } from '../backfill_client/backfill_client.mock'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -115,17 +117,16 @@ const ruleResultService = ruleResultServiceMock.create(); describe('Task Runner', () => { let mockedTaskInstance: ConcreteTaskInstance; - let alertingEventLoggerInitializer: RuleContextOpts; + let alertingEventLoggerInitializer: ContextOpts; beforeAll(() => { fakeTimer = sinon.useFakeTimers(); mockedTaskInstance = mockTaskInstance(); alertingEventLoggerInitializer = { - consumer: mockedTaskInstance.params.consumer, executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - ruleId: mockedTaskInstance.params.alertId, - ruleType, + savedObjectId: mockedTaskInstance.params.alertId, + savedObjectType: RULE_SAVED_OBJECT_TYPE, spaceId: mockedTaskInstance.params.spaceId, taskScheduledAt: mockedTaskInstance.scheduledAt, }; @@ -134,6 +135,8 @@ describe('Task Runner', () => { afterAll(() => fakeTimer.restore()); const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); + const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const backfillClient = backfillClientMock.create(); const services = alertsMock.createRuleExecutorServices(); const actionsClient = actionsClientMock.create(); const rulesClient = rulesClientMock.create(); @@ -168,11 +171,11 @@ describe('Task Runner', () => { getRulesClientWithRequest: jest.fn().mockReturnValue(rulesClient), encryptedSavedObjectsClient, logger, + backfillClient, executionContext: executionContextServiceMock.createInternalStartContract(), spaceIdToNamespace: jest.fn().mockReturnValue(undefined), basePathService: httpServiceMock.createBasePath(), eventLogger: eventLoggerMock.create(), - internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), ruleTypeRegistry, alertsService, kibanaBaseUrl: 'https://localhost:5601', @@ -274,6 +277,7 @@ describe('Task Runner', () => { }, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); @@ -327,9 +331,9 @@ describe('Task Runner', () => { testAlertingEventLogCalls({ status: 'ok' }); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith(...generateSavedObjectParams({})); + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + ...generateSavedObjectParams({}) + ); expect(taskRunnerFactoryInitializerParams.executionContext.withContext).toBeCalledTimes(1); expect(taskRunnerFactoryInitializerParams.executionContext.withContext).toHaveBeenCalledWith( @@ -377,6 +381,7 @@ describe('Task Runner', () => { taskInstance: mockedTaskInstance, context: customTaskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); @@ -461,6 +466,7 @@ describe('Task Runner', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); @@ -588,6 +594,7 @@ describe('Task Runner', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); @@ -635,6 +642,7 @@ describe('Task Runner', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); rulesClient.getAlertFromRaw.mockReturnValue(mockedRuleTypeSavedObject as Rule); @@ -707,6 +715,7 @@ describe('Task Runner', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); rulesClient.getAlertFromRaw.mockReturnValue(mockedRuleTypeSavedObject as Rule); @@ -775,6 +784,7 @@ describe('Task Runner', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); rulesClient.getAlertFromRaw.mockReturnValue(mockedRuleTypeSavedObject as Rule); @@ -861,6 +871,7 @@ describe('Task Runner', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); @@ -910,6 +921,7 @@ describe('Task Runner', () => { taskInstance: mockedTaskInstance, context: customTaskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); @@ -975,6 +987,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -1039,6 +1052,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, context: customTaskRunnerFactoryInitializerParams, inMemoryMetrics, @@ -1081,6 +1095,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -1155,6 +1170,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -1232,6 +1248,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, context: customTaskRunnerFactoryInitializerParams, inMemoryMetrics, @@ -1325,6 +1342,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -1470,6 +1488,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -1577,6 +1596,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType: ruleTypeWithCustomRecovery, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -1625,10 +1645,7 @@ describe('Task Runner', () => { ); testAlertingEventLogCalls({ - ruleContext: { - ...alertingEventLoggerInitializer, - ruleType: ruleTypeWithCustomRecovery, - }, + ruleTypeDef: ruleTypeWithCustomRecovery, activeAlerts: 1, recoveredAlerts: 1, triggeredActions: 2, @@ -1677,8 +1694,8 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: customTaskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -1740,8 +1757,8 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: customTaskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -1807,6 +1824,7 @@ describe('Task Runner', () => { const date = new Date().toISOString(); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -1887,8 +1905,8 @@ describe('Task Runner', () => { test('rescheduled the rule if the schedule has update during a task run', async () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -1926,8 +1944,8 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -1970,8 +1988,8 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2004,6 +2022,7 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: legacyTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, @@ -2040,11 +2059,11 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: originalAlertSate, }, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2062,6 +2081,7 @@ describe('Task Runner', () => { test('avoids rescheduling a failed Alert Task Runner when it throws due to failing to fetch the alert', async () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, params: { @@ -2101,8 +2121,8 @@ describe('Task Runner', () => { test('reschedules for next schedule interval if es connectivity error encountered and schedule interval is less than connectivity retry', async () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2124,13 +2144,13 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, schedule: { interval: '1d', }, }, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2146,6 +2166,7 @@ describe('Task Runner', () => { test('correctly logs warning when Alert Task Runner throws due to failing to fetch the alert in a space', async () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, params: { @@ -2153,7 +2174,6 @@ describe('Task Runner', () => { spaceId: 'test space', }, }, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2203,6 +2223,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -2210,7 +2231,6 @@ describe('Task Runner', () => { alertInstances: {}, }, }, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2296,6 +2316,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -2382,6 +2403,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -2398,7 +2420,6 @@ describe('Task Runner', () => { }, }, }, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2446,6 +2467,7 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -2470,7 +2492,6 @@ describe('Task Runner', () => { }, }, }, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2532,6 +2553,7 @@ describe('Task Runner', () => { ); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -2548,7 +2570,6 @@ describe('Task Runner', () => { }, }, }, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2591,6 +2612,7 @@ describe('Task Runner', () => { test('successfully executes the task with ephemeral tasks enabled', async () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -2598,7 +2620,6 @@ describe('Task Runner', () => { previousStartedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), }, }, - context: { ...taskRunnerFactoryInitializerParams, supportsEphemeralTasks: true, @@ -2657,17 +2678,17 @@ describe('Task Runner', () => { status: 'ok', }); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith(...generateSavedObjectParams({})); + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + ...generateSavedObjectParams({}) + ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('successfully stores successful runs', async () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2683,8 +2704,8 @@ describe('Task Runner', () => { const taskRunError = new Error(GENERIC_ERROR_MESSAGE); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2716,8 +2737,8 @@ describe('Task Runner', () => { const taskRunError = new Error(GENERIC_ERROR_MESSAGE); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2757,6 +2778,7 @@ describe('Task Runner', () => { test('successfully stores next run', async () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, @@ -2769,9 +2791,7 @@ describe('Task Runner', () => { }); await taskRunner.run(); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith( + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( ...generateSavedObjectParams({ nextRun: '1970-01-01T00:00:50.000Z', }) @@ -2781,8 +2801,8 @@ describe('Task Runner', () => { test('updates the rule saved object correctly when failed', async () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2807,9 +2827,7 @@ describe('Task Runner', () => { ); await taskRunner.run(); ruleType.executor.mockClear(); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith( + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( ...generateSavedObjectParams({ error: { message: GENERIC_ERROR_MESSAGE, @@ -2831,8 +2849,8 @@ describe('Task Runner', () => { test('caps monitoring history at 200', async () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -2917,8 +2935,8 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: { ...taskRunnerFactoryInitializerParams, actionsConfigMap, @@ -2931,9 +2949,7 @@ describe('Task Runner', () => { expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith( + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( ...generateSavedObjectParams({ status: 'warning', outcome: 'warning', @@ -3088,8 +3104,8 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: { ...taskRunnerFactoryInitializerParams, actionsConfigMap, @@ -3102,9 +3118,7 @@ describe('Task Runner', () => { expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith( + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( ...generateSavedObjectParams({ status: 'warning', outcome: 'warning', @@ -3183,8 +3197,8 @@ describe('Task Runner', () => { test('increments monitoring metrics after execution', async () => { const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -3243,6 +3257,7 @@ describe('Task Runner', () => { const date = new Date().toISOString(); const taskRunner = new TaskRunner({ ruleType, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -3267,7 +3282,6 @@ describe('Task Runner', () => { }, }, }, - context: taskRunnerFactoryInitializerParams, inMemoryMetrics, }); @@ -3308,6 +3322,7 @@ describe('Task Runner', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); ruleResultService.getLastRunResults.mockImplementation(() => ({ @@ -3351,6 +3366,7 @@ describe('Task Runner', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); ruleResultService.getLastRunResults.mockImplementation(() => ({ @@ -3369,6 +3385,7 @@ describe('Task Runner', () => { function testAlertingEventLogCalls({ ruleContext = alertingEventLoggerInitializer, + ruleTypeDef = ruleType, activeAlerts = 0, newAlerts = 0, recoveredAlerts = 0, @@ -3387,7 +3404,8 @@ describe('Task Runner', () => { softErrorFromLastRun = false, }: { status: string; - ruleContext?: RuleContextOpts; + ruleContext?: ContextOpts; + ruleTypeDef?: UntypedNormalizedRuleType; activeAlerts?: number; newAlerts?: number; recoveredAlerts?: number; @@ -3404,14 +3422,23 @@ describe('Task Runner', () => { hasReachedQueuedActionsLimit?: boolean; softErrorFromLastRun?: boolean; }) { - expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); - if (status !== 'skip') { - expect(alertingEventLogger.start).toHaveBeenCalled(); - } + expect(alertingEventLogger.initialize).toHaveBeenCalledWith({ + context: ruleContext, + runDate: new Date(DATE_1970), + ruleData: { + id: mockedTaskInstance.params.alertId, + type: ruleTypeDef, + consumer: 'bar', + }, + }); if (setRuleName) { - expect(alertingEventLogger.setRuleName).toHaveBeenCalledWith(mockedRuleTypeSavedObject.name); + expect(alertingEventLogger.addOrUpdateRuleData).toHaveBeenCalledWith({ + name: mockedRuleTypeSavedObject.name, + consumer: mockedRuleTypeSavedObject.consumer, + revision: mockedRuleTypeSavedObject.revision, + }); } else { - expect(alertingEventLogger.setRuleName).not.toHaveBeenCalled(); + expect(alertingEventLogger.addOrUpdateRuleData).not.toHaveBeenCalled(); } if (status !== 'skip') { expect(alertingEventLogger.getStartAndDuration).toHaveBeenCalled(); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 1f3d752f6dffb..c5993fe8bc560 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -9,7 +9,7 @@ import apm from 'elastic-apm-node'; import { omit } from 'lodash'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import { v4 as uuidv4 } from 'uuid'; -import { Logger } from '@kbn/core/server'; +import { ISavedObjectsRepository, Logger } from '@kbn/core/server'; import { ConcreteTaskInstance, createTaskRunError, @@ -20,6 +20,7 @@ import { nanosToMillis } from '@kbn/event-log-plugin/server'; import { getErrorSource } from '@kbn/task-manager-plugin/server/task_running'; import { ExecutionHandler, RunResult } from './execution_handler'; import { + RuleRunnerErrorStackTraceLog, RuleTaskInstance, RuleTaskRunResult, RuleTaskStateAndMetrics, @@ -27,15 +28,7 @@ import { TaskRunnerContext, } from './types'; import { getExecutorServices } from './get_executor_services'; -import { - ElasticsearchError, - executionStatusFromError, - executionStatusFromState, - getNextRun, - isRuleSnoozed, - lastRunFromError, - ruleExecutionStatusToRaw, -} from '../lib'; +import { ElasticsearchError, getNextRun, isRuleSnoozed, ruleExecutionStatusToRaw } from '../lib'; import { IntervalSchedule, RawRuleExecutionStatus, @@ -49,7 +42,7 @@ import { import { asErr, asOk, isErr, isOk, map, resolveErr, Result } from '../lib/result_type'; import { taskInstanceToAlertTaskInstance } from './alert_task_instance'; import { isAlertSavedObjectNotFoundError, isEsUnavailableError } from '../lib/is_alerting_error'; -import { partiallyUpdateRule } from '../saved_objects'; +import { partiallyUpdateRule, RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; import { AlertInstanceContext, AlertInstanceState, @@ -63,28 +56,23 @@ import { import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; import { IN_MEMORY_METRICS, InMemoryMetrics } from '../monitoring'; -import { IExecutionStatusAndMetrics } from '../lib/rule_execution_status'; import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; import { getDecryptedRule, validateRuleAndCreateFakeRequest } from './rule_loader'; import { TaskRunnerTimer, TaskRunnerTimerSpan } from './task_runner_timer'; import { RuleMonitoringService } from '../monitoring/rule_monitoring_service'; -import { ILastRun, lastRunFromState, lastRunToRaw } from '../lib/last_run_status'; -import { RunningHandler } from './running_handler'; +import { lastRunToRaw } from '../lib/last_run_status'; +import { RuleRunningHandler } from './rule_running_handler'; import { RuleResultService } from '../monitoring/rule_result_service'; import { MaintenanceWindow } from '../application/maintenance_window/types'; import { filterMaintenanceWindowsIds, getMaintenanceWindows } from './get_maintenance_windows'; import { RuleTypeRunner } from './rule_type_runner'; import { initializeAlertsClient } from '../alerts_client'; +import { processRunResults } from './lib'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; -export interface StackTraceLog { - message: ElasticsearchError; - stackTrace?: string; -} - interface TaskRunnerConstructorParams< Params extends RuleTypeParams, ExtractedParams extends RuleTypeParams, @@ -95,6 +83,9 @@ interface TaskRunnerConstructorParams< RecoveryActionGroupId extends string, AlertData extends RuleAlertData > { + context: TaskRunnerContext; + inMemoryMetrics: InMemoryMetrics; + internalSavedObjectsRepository: ISavedObjectsRepository; ruleType: NormalizedRuleType< Params, ExtractedParams, @@ -106,8 +97,6 @@ interface TaskRunnerConstructorParams< AlertData >; taskInstance: ConcreteTaskInstance; - context: TaskRunnerContext; - inMemoryMetrics: InMemoryMetrics; } export class TaskRunner< @@ -137,14 +126,15 @@ export class TaskRunner< private readonly executionId: string; private readonly ruleTypeRegistry: RuleTypeRegistry; private readonly inMemoryMetrics: InMemoryMetrics; + private readonly internalSavedObjectsRepository: ISavedObjectsRepository; private timer: TaskRunnerTimer; private alertingEventLogger: AlertingEventLogger; private usageCounter?: UsageCounter; private searchAbortController: AbortController; private cancelled: boolean; - private stackTraceLog: StackTraceLog | null; + private stackTraceLog: RuleRunnerErrorStackTraceLog | null; private ruleMonitoring: RuleMonitoringService; - private ruleRunning: RunningHandler; + private ruleRunning: RuleRunningHandler; private ruleResult: RuleResultService; private maintenanceWindows: MaintenanceWindow[] = []; private maintenanceWindowsWithoutScopedQueryIds: string[] = []; @@ -161,10 +151,11 @@ export class TaskRunner< private runDate = new Date(); constructor({ - ruleType, - taskInstance, context, inMemoryMetrics, + internalSavedObjectsRepository, + ruleType, + taskInstance, }: TaskRunnerConstructorParams< Params, ExtractedParams, @@ -187,12 +178,13 @@ export class TaskRunner< this.cancelled = false; this.executionId = uuidv4(); this.inMemoryMetrics = inMemoryMetrics; + this.internalSavedObjectsRepository = internalSavedObjectsRepository; this.timer = new TaskRunnerTimer({ logger: this.logger }); this.alertingEventLogger = new AlertingEventLogger(this.context.eventLogger); this.stackTraceLog = null; this.ruleMonitoring = new RuleMonitoringService(); - this.ruleRunning = new RunningHandler( - this.context.internalSavedObjectsRepository, + this.ruleRunning = new RuleRunningHandler( + this.internalSavedObjectsRepository, this.logger, loggerId ); @@ -208,8 +200,8 @@ export class TaskRunner< >({ context: this.context, logger: this.logger, + task: this.taskInstance, timer: this.timer, - ruleType: this.ruleType, }); this.ruleResult = new RuleResultService(); } @@ -224,7 +216,7 @@ export class TaskRunner< lastRun?: RawRuleLastRun | null; } ) { - const client = this.context.internalSavedObjectsRepository; + const client = this.internalSavedObjectsRepository; try { // Future engineer -> Here we are just checking if we need to wait for // the update of the attribute `running` in the rule's saved object @@ -302,12 +294,13 @@ export class TaskRunner< const rulesSettingsClient = this.context.getRulesSettingsClientWithRequest(fakeRequest); const ruleRunMetricsStore = new RuleRunMetricsStore(); const ruleLabel = `${this.ruleType.id}:${ruleId}: '${rule.name}'`; + const queryDelay = await rulesSettingsClient.queryDelay().get(); const ruleTypeRunnerContext = { alertingEventLogger: this.alertingEventLogger, flappingSettings: await rulesSettingsClient.flapping().get(), namespace: this.context.spaceIdToNamespace(spaceId), - queryDelaySettings: await rulesSettingsClient.queryDelay().get(), + queryDelaySec: queryDelay.delay, ruleId, ruleLogPrefix: ruleLabel, ruleRunMetricsStore, @@ -326,8 +319,17 @@ export class TaskRunner< executionId: this.executionId, logger: this.logger, maxAlerts: this.context.maxAlerts, - rule, + rule: { + id: rule.id, + name: rule.name, + tags: rule.tags, + consumer: rule.consumer, + revision: rule.revision, + alertDelay: rule.alertDelay, + params: rule.params, + }, ruleType: this.ruleType as UntypedNormalizedRuleType, + startedAt: this.taskInstance.startedAt, taskInstance: this.taskInstance, }); const executorServices = await getExecutorServices({ @@ -358,6 +360,7 @@ export class TaskRunner< maintenanceWindows: this.maintenanceWindows, maintenanceWindowsWithoutScopedQueryIds: this.maintenanceWindowsWithoutScopedQueryIds, rule, + ruleType: this.ruleType, startedAt: this.taskInstance.startedAt!, state: this.taskInstance.state, validatedParams: params, @@ -457,15 +460,21 @@ export class TaskRunner< // event that rule SO decryption fails. const namespace = this.context.spaceIdToNamespace(spaceId); this.alertingEventLogger.initialize({ - ruleId, - ruleType: this.ruleType as UntypedNormalizedRuleType, - consumer: this.ruleConsumer!, - spaceId, - executionId: this.executionId, - taskScheduledAt: this.taskInstance.scheduledAt, - ...(namespace ? { namespace } : {}), + context: { + savedObjectId: ruleId, + savedObjectType: RULE_SAVED_OBJECT_TYPE, + spaceId, + executionId: this.executionId, + taskScheduledAt: this.taskInstance.scheduledAt, + ...(namespace ? { namespace } : {}), + }, + runDate: this.runDate, + ruleData: { + id: ruleId, + type: this.ruleType as UntypedNormalizedRuleType, + consumer: this.ruleConsumer!, + }, }); - this.alertingEventLogger.start(this.runDate); if (apm.currentTransaction) { apm.currentTransaction.name = `Execute Alerting Rule`; @@ -476,6 +485,7 @@ export class TaskRunner< } this.ruleRunning.start(ruleId, this.context.spaceIdToNamespace(spaceId)); + this.logger.debug( `executing rule ${this.ruleType.id}:${ruleId} at ${this.runDate.toISOString()}` ); @@ -499,8 +509,12 @@ export class TaskRunner< // Update the consumer this.ruleConsumer = runRuleParams.rule.consumer; - // Update the rule name - this.alertingEventLogger.setRuleName(runRuleParams.rule.name); + // Update the rule data in event logger + this.alertingEventLogger.addOrUpdateRuleData({ + name: runRuleParams.rule.name, + consumer: runRuleParams.rule.consumer, + revision: runRuleParams.rule.revision, + }); // Set rule monitoring data this.ruleMonitoring.setMonitoring(runRuleParams.rule.monitoring); @@ -567,57 +581,16 @@ export class TaskRunner< const namespace = this.context.spaceIdToNamespace(spaceId); - // Getting executionStatus for backwards compatibility - const { status: executionStatus } = map< - RuleTaskStateAndMetrics, - ElasticsearchError, - IExecutionStatusAndMetrics - >( - stateWithMetrics, - (ruleRunStateWithMetrics) => - executionStatusFromState({ - stateWithMetrics: ruleRunStateWithMetrics, - lastExecutionDate: this.runDate, - ruleResultService: this.ruleResult, - }), - (err: ElasticsearchError) => executionStatusFromError(err, this.runDate) - ); - - // New consolidated statuses for lastRun - const { lastRun, metrics: executionMetrics } = map< - RuleTaskStateAndMetrics, - ElasticsearchError, - ILastRun - >( - stateWithMetrics, - (ruleRunStateWithMetrics) => lastRunFromState(ruleRunStateWithMetrics, this.ruleResult), - (err: ElasticsearchError) => lastRunFromError(err) - ); + const { executionStatus, executionMetrics, lastRun, outcome } = processRunResults({ + logger: this.logger, + logPrefix: `${this.ruleType.id}:${ruleId}`, + result: this.ruleResult, + runDate: this.runDate, + runResultWithMetrics: stateWithMetrics, + }); if (apm.currentTransaction) { - if (executionStatus.status === 'ok' || executionStatus.status === 'active') { - apm.currentTransaction.setOutcome('success'); - } else if (executionStatus.status === 'error' || executionStatus.status === 'unknown') { - apm.currentTransaction.setOutcome('failure'); - } else if (lastRun.outcome === 'succeeded') { - apm.currentTransaction.setOutcome('success'); - } else if (lastRun.outcome === 'failed') { - apm.currentTransaction.setOutcome('failure'); - } - } - - this.logger.debug( - `deprecated ruleRunStatus for ${this.ruleType.id}:${ruleId}: ${JSON.stringify( - executionStatus - )}` - ); - this.logger.debug( - `ruleRunStatus for ${this.ruleType.id}:${ruleId}: ${JSON.stringify(lastRun)}` - ); - if (executionMetrics) { - this.logger.debug( - `ruleRunMetrics for ${this.ruleType.id}:${ruleId}: ${JSON.stringify(executionMetrics)}` - ); + apm.currentTransaction.setOutcome(outcome); } // set start and duration based on event log @@ -638,9 +611,7 @@ export class TaskRunner< if (!this.cancelled) { this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_EXECUTIONS); - if (lastRun.outcome === 'failed') { - this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_FAILURES); - } else if (executionStatus.error) { + if (outcome === 'failure') { this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_FAILURES); } this.logger.debug( diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts index f28c849b2bc37..fe8bfe86a2f46 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_alerts_client.test.ts @@ -97,7 +97,9 @@ import { TAGS, VERSION, ALERT_CONSECUTIVE_MATCHES, + ALERT_RULE_EXECUTION_TIMESTAMP, } from '@kbn/rule-data-utils'; +import { backfillClientMock } from '../backfill_client/backfill_client.mock'; import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; jest.mock('uuid', () => ({ @@ -152,6 +154,8 @@ describe('Task Runner', () => { afterAll(() => fakeTimer.restore()); const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); + const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const backfillClient = backfillClientMock.create(); const services = alertsMock.createRuleExecutorServices(); const actionsClient = actionsClientMock.create(); const rulesClient = rulesClientMock.create(); @@ -195,7 +199,7 @@ describe('Task Runner', () => { spaceIdToNamespace: jest.fn().mockReturnValue(undefined), basePathService: httpServiceMock.createBasePath(), eventLogger: eventLoggerMock.create(), - internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), + backfillClient, ruleTypeRegistry, alertsService: mockAlertsService, kibanaBaseUrl: 'https://localhost:5601', @@ -284,6 +288,7 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner({ ruleType: ruleTypeWithAlerts, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -342,9 +347,9 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"hasReachedAlertLimit":false,"triggeredActionsStatus":"complete"}' ); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith(...generateSavedObjectParams({})); + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + ...generateSavedObjectParams({}) + ); expect(taskRunnerFactoryInitializerParams.executionContext.withContext).toBeCalledTimes(1); expect( @@ -381,6 +386,7 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType: ruleTypeWithAlerts, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -468,9 +474,9 @@ describe('Task Runner', () => { debugCall++, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"numberOfDelayedAlerts":0,"hasReachedAlertLimit":false,"hasReachedQueuedActionsLimit":false,"triggeredActionsStatus":"complete"}' ); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith(...generateSavedObjectParams({})); + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + ...generateSavedObjectParams({}) + ); expect(taskRunnerFactoryInitializerParams.executionContext.withContext).toBeCalledTimes(1); expect( taskRunnerFactoryInitializerParams.executionContext.withContext @@ -525,6 +531,7 @@ describe('Task Runner', () => { const taskRunner = new TaskRunner({ ruleType: ruleTypeWithAlerts, + internalSavedObjectsRepository, taskInstance: mockedTaskInstance, context: { ...taskRunnerFactoryInitializerParams, @@ -567,6 +574,7 @@ describe('Task Runner', () => { [ALERT_RULE_CATEGORY]: 'My test rule', [ALERT_RULE_CONSUMER]: 'bar', [ALERT_RULE_EXECUTION_UUID]: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + [ALERT_RULE_EXECUTION_TIMESTAMP]: DATE_1970, [ALERT_RULE_NAME]: 'rule-name', [ALERT_RULE_PARAMETERS]: { bar: true }, [ALERT_RULE_PRODUCER]: 'alerts', @@ -616,6 +624,7 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner({ ruleType: ruleTypeWithAlerts, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -652,9 +661,9 @@ describe('Task Runner', () => { expect(logger.debug).toHaveBeenCalledTimes(5); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith(...generateSavedObjectParams({})); + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + ...generateSavedObjectParams({}) + ); expect(taskRunnerFactoryInitializerParams.executionContext.withContext).toBeCalledTimes(1); expect( @@ -702,6 +711,7 @@ describe('Task Runner', () => { }); const taskRunner = new TaskRunner({ ruleType: ruleTypeWithAlerts, + internalSavedObjectsRepository, taskInstance: { ...mockedTaskInstance, state: { @@ -736,9 +746,9 @@ describe('Task Runner', () => { expect(logger.debug).toHaveBeenCalledTimes(5); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith(...generateSavedObjectParams({})); + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( + ...generateSavedObjectParams({}) + ); expect(taskRunnerFactoryInitializerParams.executionContext.withContext).toBeCalledTimes(1); expect( diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index 08d3cbb81244b..0e95ad588eda1 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -38,7 +38,7 @@ import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; import { AlertingEventLogger, - RuleContextOpts, + ContextOpts, } from '../lib/alerting_event_logger/alerting_event_logger'; import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; import { @@ -60,6 +60,8 @@ import { alertsServiceMock } from '../alerts_service/alerts_service.mock'; import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; import { TaskRunnerContext } from './types'; +import { backfillClientMock } from '../backfill_client/backfill_client.mock'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -84,17 +86,16 @@ const alertsService = alertsServiceMock.create(); describe('Task Runner Cancel', () => { let mockedTaskInstance: ConcreteTaskInstance; - let alertingEventLoggerInitializer: RuleContextOpts; + let alertingEventLoggerInitializer: ContextOpts; beforeAll(() => { fakeTimer = sinon.useFakeTimers(); mockedTaskInstance = mockTaskInstance(); alertingEventLoggerInitializer = { - consumer: mockedTaskInstance.params.consumer, executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - ruleId: mockedTaskInstance.params.alertId, - ruleType, + savedObjectId: mockedTaskInstance.params.alertId, + savedObjectType: RULE_SAVED_OBJECT_TYPE, spaceId: mockedTaskInstance.params.spaceId, taskScheduledAt: mockedTaskInstance.scheduledAt, }; @@ -104,6 +105,8 @@ describe('Task Runner Cancel', () => { const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); const services = alertsMock.createRuleExecutorServices(); + const internalSavedObjectsRepository = savedObjectsRepositoryMock.create(); + const backfillClient = backfillClientMock.create(); const actionsClient = actionsClientMock.create(); const rulesClient = rulesClientMock.create(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -135,7 +138,7 @@ describe('Task Runner Cancel', () => { spaceIdToNamespace: jest.fn().mockReturnValue(undefined), basePathService: httpServiceMock.createBasePath(), eventLogger: eventLoggerMock.create(), - internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), + backfillClient, ruleTypeRegistry, alertsService, kibanaBaseUrl: 'https://localhost:5601', @@ -205,6 +208,7 @@ describe('Task Runner Cancel', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); @@ -220,12 +224,8 @@ describe('Task Runner Cancel', () => { testAlertingEventLogCalls({ status: 'ok' }); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledTimes(1); - expect( - taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update - ).toHaveBeenCalledWith( + expect(internalSavedObjectsRepository.update).toHaveBeenCalledTimes(1); + expect(internalSavedObjectsRepository.update).toHaveBeenCalledWith( RULE_SAVED_OBJECT_TYPE, '1', { @@ -304,6 +304,7 @@ describe('Task Runner Cancel', () => { cancelAlertsOnRuleTimeout: false, }, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); @@ -370,6 +371,7 @@ describe('Task Runner Cancel', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); @@ -380,7 +382,7 @@ describe('Task Runner Cancel', () => { testLogger(); testAlertingEventLogCalls({ - ruleContext: { ...alertingEventLoggerInitializer, ruleType: updatedRuleType }, + ruleTypeDef: updatedRuleType, status: 'active', activeAlerts: 1, generatedActions: 1, @@ -432,6 +434,7 @@ describe('Task Runner Cancel', () => { taskInstance: mockedTaskInstance, context: taskRunnerFactoryInitializerParams, inMemoryMetrics, + internalSavedObjectsRepository, }); expect(AlertingEventLogger).toHaveBeenCalledTimes(1); @@ -482,6 +485,7 @@ describe('Task Runner Cancel', () => { function testAlertingEventLogCalls({ ruleContext = alertingEventLoggerInitializer, + ruleTypeDef = ruleType, activeAlerts = 0, newAlerts = 0, recoveredAlerts = 0, @@ -494,7 +498,8 @@ describe('Task Runner Cancel', () => { hasReachedQueuedActionsLimit = false, }: { status: string; - ruleContext?: RuleContextOpts; + ruleContext?: ContextOpts; + ruleTypeDef?: UntypedNormalizedRuleType; activeAlerts?: number; newAlerts?: number; recoveredAlerts?: number; @@ -506,9 +511,20 @@ describe('Task Runner Cancel', () => { hasReachedAlertLimit?: boolean; hasReachedQueuedActionsLimit?: boolean; }) { - expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); - expect(alertingEventLogger.start).toHaveBeenCalled(); - expect(alertingEventLogger.setRuleName).toHaveBeenCalledWith(mockedRuleTypeSavedObject.name); + expect(alertingEventLogger.initialize).toHaveBeenCalledWith({ + context: ruleContext, + runDate: new Date(DATE_1970), + ruleData: { + id: mockedTaskInstance.params.alertId, + type: ruleTypeDef, + consumer: 'bar', + }, + }); + expect(alertingEventLogger.addOrUpdateRuleData).toHaveBeenCalledWith({ + name: mockedRuleTypeSavedObject.name, + consumer: mockedRuleTypeSavedObject.consumer, + revision: mockedRuleTypeSavedObject.revision, + }); expect(alertingEventLogger.getStartAndDuration).toHaveBeenCalled(); expect(alertingEventLogger.done).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 43a870d57c08a..a29d9f3c0ad91 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -12,7 +12,6 @@ import { TaskRunnerFactory } from './task_runner_factory'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { loggingSystemMock, - savedObjectsRepositoryMock, httpServiceMock, savedObjectsServiceMock, elasticsearchServiceMock, @@ -35,8 +34,10 @@ import { alertsServiceMock } from '../alerts_service/alerts_service.mock'; import { schema } from '@kbn/config-schema'; import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; import { TaskRunnerContext } from './types'; +import { backfillClientMock } from '../backfill_client/backfill_client.mock'; const inMemoryMetrics = inMemoryMetricsMock.create(); +const backfillClient = backfillClientMock.create(); const executionContext = executionContextServiceMock.createSetupContract(); const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); @@ -102,6 +103,7 @@ describe('Task Runner Factory', () => { const connectorAdapterRegistry = new ConnectorAdapterRegistry(); const taskRunnerFactoryInitializerParams: jest.Mocked = { + backfillClient, data: dataPlugin, dataViews: dataViewsMock, savedObjects: savedObjectsService, @@ -115,7 +117,6 @@ describe('Task Runner Factory', () => { spaceIdToNamespace: jest.fn().mockReturnValue(undefined), basePathService: httpServiceMock.createBasePath(), eventLogger: eventLoggerMock.create(), - internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), ruleTypeRegistry: ruleTypeRegistryMock.create(), alertsService: mockAlertService, kibanaBaseUrl: 'https://localhost:5601', @@ -141,18 +142,25 @@ describe('Task Runner Factory', () => { jest.resetAllMocks(); }); - test(`throws an error if factory isn't initialized`, () => { + test(`throws an error if factory is initialized multiple times`, () => { + const factory = new TaskRunnerFactory(); + factory.initialize(taskRunnerFactoryInitializerParams); + expect(() => + factory.initialize(taskRunnerFactoryInitializerParams) + ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory already initialized"`); + }); + + test(`throws an error if create is called when factory isn't initialized`, () => { const factory = new TaskRunnerFactory(); expect(() => factory.create(ruleType, { taskInstance: mockedTaskInstance }, inMemoryMetrics) ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory not initialized"`); }); - test(`throws an error if factory is already initialized`, () => { + test(`throws an error if createAdHoc is called when factory isn't initialized`, () => { const factory = new TaskRunnerFactory(); - factory.initialize(taskRunnerFactoryInitializerParams); expect(() => - factory.initialize(taskRunnerFactoryInitializerParams) - ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory already initialized"`); + factory.createAdHoc({ taskInstance: mockedTaskInstance }) + ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory not initialized"`); }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index e8860ec5cf565..1b91cce30edb9 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -17,6 +17,8 @@ import { TaskRunner } from './task_runner'; import { NormalizedRuleType } from '../rule_type_registry'; import { InMemoryMetrics } from '../monitoring'; import { TaskRunnerContext } from './types'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { AdHocTaskRunner } from './ad_hoc_task_runner'; export class TaskRunnerFactory { private isInitialized = false; @@ -67,10 +69,27 @@ export class TaskRunnerFactory { RecoveryActionGroupId, AlertData >({ + context: this.taskRunnerContext!, + inMemoryMetrics, + internalSavedObjectsRepository: this.taskRunnerContext!.savedObjects.createInternalRepository( + [RULE_SAVED_OBJECT_TYPE] + ), ruleType, taskInstance, + }); + } + + public createAdHoc({ taskInstance }: RunContext) { + if (!this.isInitialized) { + throw new Error('TaskRunnerFactory not initialized'); + } + + return new AdHocTaskRunner({ + taskInstance, context: this.taskRunnerContext!, - inMemoryMetrics, + internalSavedObjectsRepository: this.taskRunnerContext!.savedObjects.createInternalRepository( + [RULE_SAVED_OBJECT_TYPE, AD_HOC_RUN_SAVED_OBJECT_TYPE] + ), }); } } diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index dd7b14fc7f4aa..e6701d26277e9 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -13,7 +13,6 @@ import type { SavedObjectsServiceStart, ElasticsearchServiceStart, UiSettingsServiceStart, - ISavedObjectsRepository, } from '@kbn/core/server'; import { ConcreteTaskInstance, DecoratedError } from '@kbn/task-manager-plugin/server'; import { PublicMethodsOf } from '@kbn/utility-types'; @@ -41,7 +40,6 @@ import { RuleAlertData, RuleSystemAction, RulesSettingsFlappingProperties, - RulesSettingsQueryDelayProperties, } from '../../common'; import { ActionsConfigMap } from '../lib/get_actions_config_map'; import { NormalizedRuleType } from '../rule_type_registry'; @@ -56,6 +54,8 @@ import { } from '../types'; import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; +import { BackfillClient } from '../backfill_client/backfill_client'; +import { ElasticsearchError } from '../lib'; import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; export interface RuleTaskRunResult { @@ -140,19 +140,25 @@ export type Executable< export interface RuleTypeRunnerContext { alertingEventLogger: AlertingEventLogger; - flappingSettings: RulesSettingsFlappingProperties; + flappingSettings?: RulesSettingsFlappingProperties; namespace?: string; - queryDelaySettings: RulesSettingsQueryDelayProperties; + queryDelaySec?: number; ruleId: string; ruleLogPrefix: string; ruleRunMetricsStore: RuleRunMetricsStore; spaceId: string; } +export interface RuleRunnerErrorStackTraceLog { + message: ElasticsearchError; + stackTrace?: string; +} + export interface TaskRunnerContext { actionsConfigMap: ActionsConfigMap; actionsPlugin: ActionsPluginStartContract; alertsService: AlertsService | null; + backfillClient: BackfillClient; basePathService: IBasePath; cancelAlertsOnRuleTimeout: boolean; data: DataPluginStart; @@ -164,7 +170,6 @@ export interface TaskRunnerContext { getMaintenanceWindowClientWithRequest(request: KibanaRequest): MaintenanceWindowClientApi; getRulesClientWithRequest(request: KibanaRequest): RulesClientApi; getRulesSettingsClientWithRequest(request: KibanaRequest): RulesSettingsClientApi; - internalSavedObjectsRepository: ISavedObjectsRepository; kibanaBaseUrl: string | undefined; logger: Logger; maxAlerts: number; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 858ca01c75976..d09bda0bc0cb8 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -68,6 +68,7 @@ import { import { PublicAlertFactory } from './alert/create_alert_factory'; import { RulesSettingsFlappingProperties } from '../common/rules_settings'; import { PublicAlertsClient } from './alerts_client/types'; +import { GetTimeRangeResult } from './lib/get_time_range'; export type WithoutQueryAndParams = Pick>; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; export type { RuleTypeParams }; @@ -140,11 +141,12 @@ export interface RuleExecutorOptions< services: RuleExecutorServices; spaceId: string; startedAt: Date; + startedAtOverridden: boolean; state: State; namespace?: string; flappingSettings: RulesSettingsFlappingProperties; maintenanceWindowIds?: string[]; - getTimeRange: (timeWindow?: string) => { dateStart: string; dateEnd: string }; + getTimeRange: (timeWindow?: string) => GetTimeRangeResult; } export interface RuleParamsAndRefs { diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index 561217eeae803..98908f516fc96 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -338,6 +338,21 @@ "status_order": { "type": "long" }, + "backfill": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "start": { + "type": "date" + }, + "interval": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, "metrics": { "properties": { "number_of_triggered_actions": { diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index b8be2221ec1ed..f8db21105539e 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -151,6 +151,13 @@ export const EventSchema = schema.maybe( uuid: ecsString(), status: ecsString(), status_order: ecsStringOrNumber(), + backfill: schema.maybe( + schema.object({ + id: ecsString(), + start: ecsDate(), + interval: ecsString(), + }) + ), metrics: schema.maybe( schema.object({ number_of_triggered_actions: ecsStringOrNumber(), diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index 6d0ec3635ab41..aa91f7fc5c2d6 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -113,6 +113,21 @@ exports.EcsCustomPropertyMappings = { status_order: { type: 'long', }, + backfill: { + properties: { + id: { + type: 'keyword', + ignore_above: 1024, + }, + start: { + type: 'date', + }, + interval: { + type: 'keyword', + ignore_above: 1024, + }, + }, + }, metrics: { properties: { number_of_triggered_actions: { diff --git a/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index c83a5111772f4..2a7d8279e2fa1 100644 --- a/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/observability_solution/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -52,6 +52,7 @@ const STARTED_AT_MOCK_DATE = new Date(); const mockOptions = { executionId: '', startedAt: mockNow, + startedAtOverridden: false, previousStartedAt: null, state: {}, spaceId: '', diff --git a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts index b7e34fb7fd181..8f65682c68fb1 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts @@ -55,6 +55,7 @@ const STARTED_AT_MOCK_DATE = new Date(); const mockQuery = 'mockQuery'; const mockOptions = { executionId: '', + startedAtOverridden: false, startedAt: STARTED_AT_MOCK_DATE, previousStartedAt: null, params: { diff --git a/x-pack/plugins/observability_solution/slo/server/lib/rules/slo_burn_rate/executor.test.ts b/x-pack/plugins/observability_solution/slo/server/lib/rules/slo_burn_rate/executor.test.ts index 74bae62db0078..c3b830f445b3b 100644 --- a/x-pack/plugins/observability_solution/slo/server/lib/rules/slo_burn_rate/executor.test.ts +++ b/x-pack/plugins/observability_solution/slo/server/lib/rules/slo_burn_rate/executor.test.ts @@ -198,6 +198,7 @@ describe('BurnRateRuleExecutor', () => { executor({ params: someRuleParamsWithWindows({ sloId: 'non-existent' }), startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -219,6 +220,7 @@ describe('BurnRateRuleExecutor', () => { const result = await executor({ params: someRuleParamsWithWindows({ sloId: slo.id }), startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -268,6 +270,7 @@ describe('BurnRateRuleExecutor', () => { await executor({ params: ruleParams, startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -314,6 +317,7 @@ describe('BurnRateRuleExecutor', () => { await executor({ params: ruleParams, startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -370,6 +374,7 @@ describe('BurnRateRuleExecutor', () => { await executor({ params: ruleParams, startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -503,6 +508,7 @@ describe('BurnRateRuleExecutor', () => { await executor({ params: ruleParams, startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -614,6 +620,7 @@ describe('BurnRateRuleExecutor', () => { await executor({ params: ruleParams, startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts index aa82fbdeb0b16..528354eed3d05 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts @@ -134,6 +134,11 @@ it('matches snapshot', () => { "required": false, "type": "keyword", }, + "kibana.alert.rule.execution.timestamp": Object { + "array": false, + "required": false, + "type": "date", + }, "kibana.alert.rule.execution.uuid": Object { "array": false, "required": false, diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index 465c49f9aa9b7..f64542fef471c 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -146,6 +146,7 @@ function createRule(shouldWriteAlerts: boolean = true) { }, spaceId: 'spaceId', startedAt, + startedAtOverridden: false, state, flappingSettings: DEFAULT_FLAPPING_SETTINGS, getTimeRange: () => { diff --git a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts index 5436043cbfd81..58ec5ea0818d1 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_persistence_rule_type_wrapper.ts @@ -24,6 +24,7 @@ import { ALERT_WORKFLOW_STATUS, TIMESTAMP, VERSION, + ALERT_RULE_EXECUTION_TIMESTAMP, } from '@kbn/rule-data-utils'; import { mapKeys, snakeCase } from 'lodash/fp'; import type { IRuleDataClient } from '..'; @@ -65,6 +66,7 @@ const augmentAlerts = ({ return { ...alert, _source: { + [ALERT_RULE_EXECUTION_TIMESTAMP]: new Date(), [ALERT_START]: currentTimeOverride ?? new Date(), [ALERT_LAST_DETECTED]: currentTimeOverride ?? new Date(), [VERSION]: kibanaVersion, @@ -247,7 +249,13 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper ...options, services: { ...options.services, - alertWithPersistence: async (alerts, refresh, maxAlerts = undefined, enrichAlerts) => { + alertWithPersistence: async ( + alerts, + refresh, + maxAlerts = undefined, + enrichAlerts, + currentTimeOverride + ) => { const numAlerts = alerts.length; logger.debug(`Found ${numAlerts} alerts.`); @@ -299,7 +307,7 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper alerts: enrichedAlerts, options, kibanaVersion: ruleDataClient.kibanaVersion, - currentTimeOverride: undefined, + currentTimeOverride, }); const response = await ruleDataClientWriter.bulk({ diff --git a/x-pack/plugins/rule_registry/server/utils/persistence_types.ts b/x-pack/plugins/rule_registry/server/utils/persistence_types.ts index 328e5185a2b80..1ff6a6e62d743 100644 --- a/x-pack/plugins/rule_registry/server/utils/persistence_types.ts +++ b/x-pack/plugins/rule_registry/server/utils/persistence_types.ts @@ -38,7 +38,8 @@ export type PersistenceAlertService = ( _id: string; _source: T; }> - > + >, + currentTimeOverride?: Date ) => Promise>; export type SuppressedAlertService = ( diff --git a/x-pack/plugins/rule_registry/server/utils/rule_executor.test_helpers.ts b/x-pack/plugins/rule_registry/server/utils/rule_executor.test_helpers.ts index 4ef589edadacb..588da8f9db7f2 100644 --- a/x-pack/plugins/rule_registry/server/utils/rule_executor.test_helpers.ts +++ b/x-pack/plugins/rule_registry/server/utils/rule_executor.test_helpers.ts @@ -53,6 +53,7 @@ export const createDefaultAlertExecutorOptions = < maintenanceWindowIds?: string[]; }): RuleExecutorOptions => ({ startedAt, + startedAtOverridden: false, rule: { id: alertId, updatedBy: null, 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 3025f4ce60003..db4aae642a56f 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 @@ -90,6 +90,8 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/getActionErrorLog", "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", + "alerting:alert-type/my-feature/rule/getBackfill", + "alerting:alert-type/my-feature/rule/findBackfill", ] `); }); @@ -177,6 +179,8 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/getActionErrorLog", "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", + "alerting:alert-type/my-feature/rule/getBackfill", + "alerting:alert-type/my-feature/rule/findBackfill", "alerting:alert-type/my-feature/alert/get", "alerting:alert-type/my-feature/alert/find", "alerting:alert-type/my-feature/alert/getAuthorizedAlertsIndices", @@ -224,6 +228,8 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/getActionErrorLog", "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", + "alerting:alert-type/my-feature/rule/getBackfill", + "alerting:alert-type/my-feature/rule/findBackfill", "alerting:alert-type/my-feature/rule/create", "alerting:alert-type/my-feature/rule/delete", "alerting:alert-type/my-feature/rule/update", @@ -241,6 +247,8 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/bulkDisable", "alerting:alert-type/my-feature/rule/unsnooze", "alerting:alert-type/my-feature/rule/runSoon", + "alerting:alert-type/my-feature/rule/scheduleBackfill", + "alerting:alert-type/my-feature/rule/deleteBackfill", ] `); }); @@ -329,6 +337,8 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/getActionErrorLog", "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", + "alerting:alert-type/my-feature/rule/getBackfill", + "alerting:alert-type/my-feature/rule/findBackfill", "alerting:alert-type/my-feature/rule/create", "alerting:alert-type/my-feature/rule/delete", "alerting:alert-type/my-feature/rule/update", @@ -346,6 +356,8 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/bulkDisable", "alerting:alert-type/my-feature/rule/unsnooze", "alerting:alert-type/my-feature/rule/runSoon", + "alerting:alert-type/my-feature/rule/scheduleBackfill", + "alerting:alert-type/my-feature/rule/deleteBackfill", "alerting:alert-type/my-feature/alert/get", "alerting:alert-type/my-feature/alert/find", "alerting:alert-type/my-feature/alert/getAuthorizedAlertsIndices", @@ -394,6 +406,8 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/getActionErrorLog", "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", + "alerting:alert-type/my-feature/rule/getBackfill", + "alerting:alert-type/my-feature/rule/findBackfill", "alerting:alert-type/my-feature/rule/create", "alerting:alert-type/my-feature/rule/delete", "alerting:alert-type/my-feature/rule/update", @@ -411,6 +425,8 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/bulkDisable", "alerting:alert-type/my-feature/rule/unsnooze", "alerting:alert-type/my-feature/rule/runSoon", + "alerting:alert-type/my-feature/rule/scheduleBackfill", + "alerting:alert-type/my-feature/rule/deleteBackfill", "alerting:readonly-alert-type/my-feature/rule/get", "alerting:readonly-alert-type/my-feature/rule/getRuleState", "alerting:readonly-alert-type/my-feature/rule/getAlertSummary", @@ -418,6 +434,8 @@ describe(`feature_privilege_builder`, () => { "alerting:readonly-alert-type/my-feature/rule/getActionErrorLog", "alerting:readonly-alert-type/my-feature/rule/find", "alerting:readonly-alert-type/my-feature/rule/getRuleExecutionKPI", + "alerting:readonly-alert-type/my-feature/rule/getBackfill", + "alerting:readonly-alert-type/my-feature/rule/findBackfill", ] `); }); @@ -510,6 +528,8 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/getActionErrorLog", "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", + "alerting:alert-type/my-feature/rule/getBackfill", + "alerting:alert-type/my-feature/rule/findBackfill", "alerting:alert-type/my-feature/rule/create", "alerting:alert-type/my-feature/rule/delete", "alerting:alert-type/my-feature/rule/update", @@ -527,6 +547,8 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/bulkDisable", "alerting:alert-type/my-feature/rule/unsnooze", "alerting:alert-type/my-feature/rule/runSoon", + "alerting:alert-type/my-feature/rule/scheduleBackfill", + "alerting:alert-type/my-feature/rule/deleteBackfill", "alerting:readonly-alert-type/my-feature/rule/get", "alerting:readonly-alert-type/my-feature/rule/getRuleState", "alerting:readonly-alert-type/my-feature/rule/getAlertSummary", @@ -534,6 +556,8 @@ describe(`feature_privilege_builder`, () => { "alerting:readonly-alert-type/my-feature/rule/getActionErrorLog", "alerting:readonly-alert-type/my-feature/rule/find", "alerting:readonly-alert-type/my-feature/rule/getRuleExecutionKPI", + "alerting:readonly-alert-type/my-feature/rule/getBackfill", + "alerting:readonly-alert-type/my-feature/rule/findBackfill", "alerting:another-alert-type/my-feature/alert/get", "alerting:another-alert-type/my-feature/alert/find", "alerting:another-alert-type/my-feature/alert/getAuthorizedAlertsIndices", 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 4c1df64e18df2..c0b7fa2ea8ab7 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 @@ -25,6 +25,8 @@ const readOperations: Record = { 'getActionErrorLog', 'find', 'getRuleExecutionKPI', + 'getBackfill', + 'findBackfill', ], alert: ['get', 'find', 'getAuthorizedAlertsIndices', 'getAlertSummary'], }; @@ -48,6 +50,8 @@ const writeOperations: Record = { 'bulkDisable', 'unsnooze', 'runSoon', + 'scheduleBackfill', + 'deleteBackfill', ], alert: ['update'], }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_rule_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_rule_type.test.ts index e3a5e24bd14c2..767c01f02b187 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_rule_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_actions_legacy/logic/notifications/legacy_rules_notification_rule_type.test.ts @@ -157,6 +157,7 @@ describe('legacyRules_notification_rule_type', () => { state: {}, spaceId: '', startedAt: new Date('2019-12-14T16:40:33.400Z'), + startedAtOverridden: false, previousStartedAt: new Date('2019-12-13T16:40:33.400Z'), rule: { id: '1111', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts index 9281c317ab2e0..7a3fd49dcd2d1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts @@ -286,6 +286,7 @@ export const previewRulesRoute = async ( }, spaceId, startedAt: startedAt.toDate(), + startedAtOverridden: true, state: statePreview, logger, flappingSettings: DISABLE_FLAPPING_SETTINGS, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index 4577b83540e5b..644dff4ec761e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -125,6 +125,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = params, previousStartedAt, startedAt, + startedAtOverridden, services, spaceId, state, @@ -350,14 +351,15 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = lists: params.exceptionsList, }); + const alertTimestampOverride = isPreview || startedAtOverridden ? startedAt : undefined; const bulkCreate = bulkCreateFactory( alertWithPersistence, refresh, ruleExecutionLogger, - experimentalFeatures + experimentalFeatures, + alertTimestampOverride ); - const alertTimestampOverride = isPreview ? startedAt : undefined; const legacySignalFields: string[] = Object.keys(aadFieldConversion); const wrapHits = wrapHitsFactory({ ignoreFields: [...ignoreFields, ...legacySignalFields], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts index 822d0314375d4..add98067223aa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/bulk_create_factory.ts @@ -35,7 +35,8 @@ export const bulkCreateFactory = alertWithPersistence: PersistenceAlertService, refreshForBulkCreate: RefreshTypes, ruleExecutionLogger: IRuleExecutionLogForExecutors, - experimentalFeatures?: ExperimentalFeatures + experimentalFeatures?: ExperimentalFeatures, + currentTimeOverride?: Date ) => async ( wrappedDocs: Array>, @@ -86,7 +87,8 @@ export const bulkCreateFactory = })), refreshForBulkCreate, maxAlerts, - enrichAlertsWrapper + enrichAlertsWrapper, + currentTimeOverride ); const end = performance.now(); diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts index a67acc2efe01c..781bf37da84e3 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.test.ts @@ -905,6 +905,7 @@ async function invokeExecutor({ return await ruleType.executor({ executionId: uuidv4(), startedAt: new Date(), + startedAtOverridden: false, previousStartedAt: new Date(), services: ruleServices as unknown as RuleExecutorServices< EsQueryRuleState, diff --git a/x-pack/plugins/stack_alerts/server/rule_types/index_threshold/rule_type.test.ts b/x-pack/plugins/stack_alerts/server/rule_types/index_threshold/rule_type.test.ts index def4f2eadd8da..546ab5239561f 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/index_threshold/rule_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/index_threshold/rule_type.test.ts @@ -192,6 +192,7 @@ describe('ruleType', () => { await ruleType.executor({ executionId: uuidv4(), startedAt: new Date(), + startedAtOverridden: false, previousStartedAt: new Date(), services: alertServices as unknown as RuleExecutorServices< {}, @@ -287,6 +288,7 @@ describe('ruleType', () => { await ruleType.executor({ executionId: uuidv4(), startedAt: new Date(), + startedAtOverridden: false, previousStartedAt: new Date(), services: customAlertServices as unknown as RuleExecutorServices< {}, @@ -356,6 +358,7 @@ describe('ruleType', () => { await ruleType.executor({ executionId: uuidv4(), startedAt: new Date(), + startedAtOverridden: false, previousStartedAt: new Date(), services: customAlertServices as unknown as RuleExecutorServices< {}, @@ -424,6 +427,7 @@ describe('ruleType', () => { await ruleType.executor({ executionId: uuidv4(), startedAt: new Date(), + startedAtOverridden: false, previousStartedAt: new Date(), services: alertServices as unknown as RuleExecutorServices< {}, diff --git a/x-pack/plugins/task_manager/server/integration_tests/__snapshots__/task_priority_check.test.ts.snap b/x-pack/plugins/task_manager/server/integration_tests/__snapshots__/task_priority_check.test.ts.snap index 75726039709fa..412e2ae77bb5b 100644 --- a/x-pack/plugins/task_manager/server/integration_tests/__snapshots__/task_priority_check.test.ts.snap +++ b/x-pack/plugins/task_manager/server/integration_tests/__snapshots__/task_priority_check.test.ts.snap @@ -1,3 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Task priority checks detects tasks with priority definitions 1`] = `Array []`; +exports[`Task priority checks detects tasks with priority definitions 1`] = ` +Array [ + Object { + "priority": 1, + "taskType": "ad_hoc_run-backfill", + }, +] +`; diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 46a3119ab0d22..2b4f8e2cd8aff 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -199,7 +199,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ])}`, `--xpack.actions.enableFooterInEmail=${enableFooterInEmail}`, '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', - '--xpack.alerting.invalidateApiKeysTask.interval="15s"', + '--xpack.alerting.invalidateApiKeysTask.removalDelay="1s"', '--xpack.alerting.healthCheck.interval="1s"', '--xpack.alerting.rules.minimumScheduleInterval.value="1s"', '--xpack.alerting.rules.run.alerts.max=20', diff --git a/x-pack/test/alerting_api_integration/common/plugins/aad/server/plugin.ts b/x-pack/test/alerting_api_integration/common/plugins/aad/server/plugin.ts index a55417d668595..5b93bb8dfc1df 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/aad/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/aad/server/plugin.ts @@ -17,6 +17,7 @@ import { schema } from '@kbn/config-schema'; import { EncryptedSavedObjectsPluginStart } from '@kbn/encrypted-saved-objects-plugin/server'; import { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server/saved_objects'; interface FixtureSetupDeps { spaces?: SpacesPluginSetup; @@ -50,7 +51,7 @@ export class FixturePlugin implements Plugin({ - type: 'api_key_pending_invalidation', + type: API_KEY_PENDING_INVALIDATION_TYPE, }); return res.ok({ body: { apiKeysToInvalidate: findResult.saved_objects }, @@ -395,6 +398,26 @@ export function defineRoutes( } ); + router.post( + { + path: `/api/alerts_fixture/api_key_invalidation/_run_soon`, + validate: {}, + }, + async function ( + _: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + const taskId = `Alerts-alerts_invalidate_api_keys`; + try { + const taskManager = await taskManagerStart; + return res.ok({ body: await taskManager.runSoon(taskId) }); + } catch (err) { + return res.ok({ body: { id: taskId, error: `${err}` } }); + } + } + ); + router.get( { path: '/api/alerts_fixture/rule/{id}/_get_api_key', diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/rule_types.ts similarity index 94% rename from x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts rename to x-pack/test/alerting_api_integration/common/plugins/alerts/server/rule_types.ts index 34367ce2d8b4d..109f01f6b6d57 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/rule_types.ts @@ -51,7 +51,7 @@ export const DeepContextVariables = { dateL: '2023-04-20T04:13:17.858Z', }; -function getAlwaysFiringAlertType() { +function getAlwaysFiringRuleType() { const paramsSchema = schema.object({ index: schema.string(), reference: schema.string(), @@ -138,7 +138,7 @@ async function alwaysFiringExecutor(alertExecutorOptions: any) { }; } -function getCumulativeFiringAlertType() { +function getCumulativeFiringRuleType() { interface State extends RuleTypeState { runCount?: number; } @@ -183,7 +183,7 @@ function getCumulativeFiringAlertType() { return result; } -function getNeverFiringAlertType() { +function getNeverFiringRuleType() { const paramsSchema = schema.object({ index: schema.string(), reference: schema.string(), @@ -230,7 +230,7 @@ function getNeverFiringAlertType() { return result; } -function getFailingAlertType() { +function getFailingRuleType() { const paramsSchema = schema.object({ index: schema.string(), reference: schema.string(), @@ -327,7 +327,7 @@ function getExceedsAlertLimitRuleType() { return result; } -function getAuthorizationAlertType(core: CoreSetup) { +function getAuthorizationRuleType(core: CoreSetup) { const paramsSchema = schema.object({ callClusterAuthorizationIndex: schema.string(), savedObjectsClientType: schema.string(), @@ -422,7 +422,7 @@ function getAuthorizationAlertType(core: CoreSetup) { return result; } -function getValidationAlertType() { +function getValidationRuleType() { const paramsSchema = schema.object({ param1: schema.string(), }); @@ -451,7 +451,7 @@ function getValidationAlertType() { return result; } -function getPatternFiringAlertType() { +function getPatternFiringRuleType() { const paramsSchema = schema.object({ pattern: schema.recordOf( schema.string(), @@ -639,7 +639,7 @@ function getPatternFiringAlertsAsDataRuleType() { return result; } -function getPatternSuccessOrFailureAlertType() { +function getPatternSuccessOrFailureRuleType() { const paramsSchema = schema.object({ pattern: schema.arrayOf(schema.oneOf([schema.boolean(), schema.string()])), }); @@ -684,7 +684,7 @@ function getPatternSuccessOrFailureAlertType() { return result; } -function getPatternFiringAutoRecoverFalseAlertType() { +function getPatternFiringAutoRecoverFalseRuleType() { const paramsSchema = schema.object({ pattern: schema.recordOf( schema.string(), @@ -705,6 +705,7 @@ function getPatternFiringAutoRecoverFalseAlertType() { defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', isExportable: true, + ruleTaskTimeout: '10s', autoRecoverAlerts: false, async executor(alertExecutorOptions) { const { services, state, params } = alertExecutorOptions; @@ -745,7 +746,14 @@ function getPatternFiringAutoRecoverFalseAlertType() { deep: DeepContextVariables, }); } else if (typeof scheduleByPattern === 'string') { - services.alertFactory.create(instanceId).scheduleActions('default', scheduleByPattern); + if (scheduleByPattern === 'error') { + throw new Error('rule executor error'); + } else if (scheduleByPattern === 'timeout') { + // delay longer than the timeout + await new Promise((r) => setTimeout(r, 12000)); + } else { + services.alertFactory.create(instanceId).scheduleActions('default', scheduleByPattern); + } } } @@ -1087,12 +1095,12 @@ async function getSignalDocs(es: ElasticsearchClient, source: string, reference: return result?.body?.hits?.hits || []; } -export function defineAlertTypes( +export function defineRuleTypes( core: CoreSetup, { alerting, ruleRegistry }: Pick, logger: Logger ) { - const noopAlertType: RuleType<{}, {}, {}, {}, {}, 'default'> = { + const noopRuleType: RuleType<{}, {}, {}, {}, {}, 'default'> = { id: 'test.noop', name: 'Test: Noop', actionGroups: [{ id: 'default', name: 'Default' }], @@ -1108,7 +1116,7 @@ export function defineAlertTypes( params: schema.any(), }, }; - const goldNoopAlertType: RuleType<{}, {}, {}, {}, {}, 'default'> = { + const goldNoopRuleType: RuleType<{}, {}, {}, {}, {}, 'default'> = { id: 'test.gold.noop', name: 'Test: Noop', actionGroups: [{ id: 'default', name: 'Default' }], @@ -1124,7 +1132,7 @@ export function defineAlertTypes( params: schema.any(), }, }; - const onlyContextVariablesAlertType: RuleType<{}, {}, {}, {}, {}, 'default'> = { + const onlyContextVariablesRuleType: RuleType<{}, {}, {}, {}, {}, 'default'> = { id: 'test.onlyContextVariables', name: 'Test: Only Context Variables', actionGroups: [{ id: 'default', name: 'Default' }], @@ -1143,7 +1151,7 @@ export function defineAlertTypes( params: schema.any(), }, }; - const onlyStateVariablesAlertType: RuleType<{}, {}, {}, {}, {}, 'default'> = { + const onlyStateVariablesRuleType: RuleType<{}, {}, {}, {}, {}, 'default'> = { id: 'test.onlyStateVariables', name: 'Test: Only State Variables', actionGroups: [{ id: 'default', name: 'Default' }], @@ -1162,7 +1170,7 @@ export function defineAlertTypes( params: schema.any(), }, }; - const throwAlertType: RuleType<{}, {}, {}, {}, {}, 'default'> = { + const throwRuleType: RuleType<{}, {}, {}, {}, {}, 'default'> = { id: 'test.throw', name: 'Test: Throw', actionGroups: [ @@ -1214,7 +1222,7 @@ export function defineAlertTypes( }; return result; } - const exampleAlwaysFiringAlertType: RuleType<{}, {}, {}, {}, {}, 'small' | 'medium' | 'large'> = { + const exampleAlwaysFiringRuleType: RuleType<{}, {}, {}, {}, {}, 'small' | 'medium' | 'large'> = { id: 'example.always-firing', name: 'Always firing', actionGroups: [ @@ -1293,28 +1301,28 @@ export function defineAlertTypes( }, }; - alerting.registerType(getAlwaysFiringAlertType()); - alerting.registerType(getCumulativeFiringAlertType()); - alerting.registerType(getNeverFiringAlertType()); - alerting.registerType(getFailingAlertType()); - alerting.registerType(getValidationAlertType()); - alerting.registerType(getAuthorizationAlertType(core)); - alerting.registerType(noopAlertType); - alerting.registerType(onlyContextVariablesAlertType); - alerting.registerType(onlyStateVariablesAlertType); - alerting.registerType(getPatternFiringAlertType()); - alerting.registerType(throwAlertType); + alerting.registerType(getAlwaysFiringRuleType()); + alerting.registerType(getCumulativeFiringRuleType()); + alerting.registerType(getNeverFiringRuleType()); + alerting.registerType(getFailingRuleType()); + alerting.registerType(getValidationRuleType()); + alerting.registerType(getAuthorizationRuleType(core)); + alerting.registerType(noopRuleType); + alerting.registerType(onlyContextVariablesRuleType); + alerting.registerType(onlyStateVariablesRuleType); + alerting.registerType(getPatternFiringRuleType()); + alerting.registerType(throwRuleType); alerting.registerType(getLongRunningRuleType()); - alerting.registerType(goldNoopAlertType); - alerting.registerType(exampleAlwaysFiringAlertType); + alerting.registerType(goldNoopRuleType); + alerting.registerType(exampleAlwaysFiringRuleType); alerting.registerType(multipleSearchesRuleType); alerting.registerType(getLongRunningPatternRuleType()); alerting.registerType(getLongRunningPatternRuleType(false)); alerting.registerType(getCancellableRuleType()); - alerting.registerType(getPatternSuccessOrFailureAlertType()); + alerting.registerType(getPatternSuccessOrFailureRuleType()); alerting.registerType(getExceedsAlertLimitRuleType()); alerting.registerType(getAlwaysFiringAlertAsDataRuleType(logger, { ruleRegistry })); - alerting.registerType(getPatternFiringAutoRecoverFalseAlertType()); + alerting.registerType(getPatternFiringAutoRecoverFalseRuleType()); alerting.registerType(getPatternFiringAlertsAsDataRuleType()); alerting.registerType(getWaitingRuleType(logger)); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/api_key.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/api_key.ts new file mode 100644 index 0000000000000..44e2c3f0252af --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/api_key.ts @@ -0,0 +1,261 @@ +/* + * 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 { ALERTING_CASES_SAVED_OBJECT_INDEX, SavedObject } from '@kbn/core-saved-objects-server'; +import { AdHocRunSO } from '@kbn/alerting-plugin/server/data/ad_hoc_run/types'; +import { get } from 'lodash'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server/saved_objects'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; +import { SuperuserAtSpace1 } from '../../../../scenarios'; +import { + getEventLog, + getTestRuleData, + getUrlPrefix, + ObjectRemover, +} from '../../../../../common/lib'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function apiKeyBackfillTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + const retry = getService('retry'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('backfill api key invalidation', () => { + const objectRemover = new ObjectRemover(supertest); + + beforeEach(async () => { + await runInvalidateTask(); + }); + + after(() => objectRemover.removeAll()); + + async function getAdHocRunSO(id: string) { + const result = await es.get({ + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `ad_hoc_run_params:${id}`, + }); + return result._source; + } + + async function runInvalidateTask() { + // Invoke the invalidate API key task + await supertest + .post('/api/alerts_fixture/api_key_invalidation/_run_soon') + .set('kbn-xsrf', 'xxx') + .expect(200); + } + + async function getApiKeysPendingInvalidation() { + const result = await es.search({ + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + body: { + query: { + term: { type: 'api_key_pending_invalidation' }, + }, + }, + }); + return result.hits.hits.map((hit) => hit._source); + } + + function getRule(overwrites = {}) { + return getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: [true, false, true], + }, + }, + schedule: { interval: '12h' }, + ...overwrites, + }); + } + + function testExpectedRule(result: any, ruleId: string | undefined, isSO: boolean) { + if (!isSO) { + expect(result.rule.id).to.eql(ruleId); + expect(result.rule.name).to.eql('abc'); + expect(result.rule.tags).to.eql(['foo']); + expect(result.rule.params).to.eql({ + pattern: { + instance: [true, false, true], + }, + }); + expect(result.rule.enabled).to.eql(true); + expect(result.rule.consumer).to.eql('alertsFixture'); + expect(result.rule.schedule.interval).to.eql('12h'); + expect(result.rule.rule_type_id).to.eql('test.patternFiringAutoRecoverFalse'); + expect(result.rule.api_key_owner).to.eql('elastic'); + expect(result.rule.api_key_created_by_user).to.eql(false); + expect(result.rule.created_by).to.eql('elastic'); + expect(result.rule.updated_by).to.eql('elastic'); + expect(typeof result.rule.created_at).to.be('string'); + expect(typeof result.rule.updated_at).to.be('string'); + } else { + expect(result.rule.name).to.eql('abc'); + expect(result.rule.tags).to.eql(['foo']); + expect(result.rule.params).to.eql({ + pattern: { + instance: [true, false, true], + }, + }); + expect(result.rule.enabled).to.eql(true); + expect(result.rule.consumer).to.eql('alertsFixture'); + expect(result.rule.schedule.interval).to.eql('12h'); + expect(result.rule.alertTypeId).to.eql('test.patternFiringAutoRecoverFalse'); + expect(result.rule.apiKeyOwner).to.eql('superuser'); + expect(result.rule.apiKeyCreatedByUser).to.eql(false); + expect(result.rule.createdBy).to.eql('superuser'); + expect(result.rule.updatedBy).to.eql('superuser'); + expect(typeof result.rule.createdAt).to.be('string'); + expect(typeof result.rule.updatedAt).to.be('string'); + } + } + + it('should wait to invalidate API key until backfill for rule is complete', async () => { + const spaceId = SuperuserAtSpace1.space.id; + + // create 2 rules + const rresponse1 = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send(getRule()) + .expect(200); + const ruleId1 = rresponse1.body.id; + + const rresponse2 = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send(getRule()) + .expect(200); + const ruleId2 = rresponse2.body.id; + + // schedule backfill for rule 1 + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send([ + { + rule_id: ruleId1, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-22T12:00:00.000Z', + }, + ]) + .expect(200); + const result = response.body; + const backfillId = result[0].id; + const schedule = result[0].schedule; + + // check that the ad hoc run SO was created + const adHocRunSO1 = (await getAdHocRunSO(result[0].id)) as SavedObject; + const adHocRun1: AdHocRunSO = get(adHocRunSO1, 'ad_hoc_run_params'); + expect(typeof adHocRun1.apiKeyId).to.be('string'); + expect(typeof adHocRun1.apiKeyToUse).to.be('string'); + expect(typeof adHocRun1.createdAt).to.be('string'); + expect(adHocRun1.duration).to.eql('12h'); + expect(adHocRun1.enabled).to.eql(true); + expect(adHocRun1.start).to.eql('2023-10-19T12:00:00.000Z'); + expect(adHocRun1.end).to.eql('2023-10-22T12:00:00.000Z'); + expect(adHocRun1.status).to.eql('pending'); + expect(adHocRun1.spaceId).to.eql(spaceId); + testExpectedRule(adHocRun1, undefined, true); + expect(adHocRun1.schedule).to.eql([ + { + runAt: '2023-10-20T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-20T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-21T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-21T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-22T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-22T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + + // delete both rules which will mark the api keys for invalidation + await supertestWithoutAuth + .delete(`${getUrlPrefix(spaceId)}/api/alerting/rule/${ruleId1}`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .expect(204); + await supertestWithoutAuth + .delete(`${getUrlPrefix(spaceId)}/api/alerting/rule/${ruleId2}`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .expect(204); + + // get the "api_key_pending_invalidation" saved objects + await retry.try(async () => { + const results = await getApiKeysPendingInvalidation(); + expect(results.length).to.eql(2); + return results; + }); + + // invoke the invalidate task + await runInvalidateTask(); + + // wait until one of the api_key_pending_invalidation SOs is deleted + await retry.try(async () => { + const results = await getApiKeysPendingInvalidation(); + expect(results.length).to.eql(1); + return results; + }); + + // wait for the backfill to complete and periodically check that one API key is still awaiting invalidation + const executeBackfillEvents: IValidatedEvent[] = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: backfillId, + provider: 'alerting', + actions: new Map([['execute-backfill', { equal: schedule.length }]]), + }); + }); + + // all the executions should have ended in success + for (const e of executeBackfillEvents) { + expect(e?.event?.outcome).to.eql('success'); + } + + // invoke the invalidate task + await runInvalidateTask(); + + // pending API key should now be deleted because backfill is done + await retry.try(async () => { + const results = await getApiKeysPendingInvalidation(); + expect(results.length).to.eql(0); + return results; + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/delete.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/delete.ts new file mode 100644 index 0000000000000..41d73f7e3f2ea --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/delete.ts @@ -0,0 +1,328 @@ +/* + * 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 { GetResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { UserAtSpaceScenarios } from '../../../../scenarios'; +import { + getTestRuleData, + getUrlPrefix, + ObjectRemover, + TaskManagerDoc, +} from '../../../../../common/lib'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function deleteBackfillTests({ getService }: FtrProviderContext) { + const es = getService('es'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('delete backfill', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + function getRule(overwrites = {}) { + return getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: [true, false, true], + }, + }, + schedule: { interval: '12h' }, + ...overwrites, + }); + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + const apiOptions = { + spaceId: space.id, + username: user.username, + password: user.password, + }; + it('should handle delete backfill request appropriately', async () => { + // create 2 rules + const rresponse1 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId1 = rresponse1.body.id; + objectRemover.add(apiOptions.spaceId, ruleId1, 'rule', 'alerting'); + + const rresponse2 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId2 = rresponse2.body.id; + objectRemover.add(apiOptions.spaceId, ruleId2, 'rule', 'alerting'); + + // schedule backfill for both rules + const scheduleResponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([ + { + // set a long time range so the backfill doesn't finish running and get deleted + rule_id: ruleId1, + start: '2023-10-19T12:00:00.000Z', + end: '2023-11-19T12:00:00.000Z', + }, + { + // set a long time range so the backfill doesn't finish running and get deleted + rule_id: ruleId2, + start: '2023-10-19T12:00:00.000Z', + end: '2023-11-19T12:00:00.000Z', + }, + ]); + + const scheduleResult = scheduleResponse.body; + expect(scheduleResult.length).to.eql(2); + const backfillId1 = scheduleResult[0].id; + const backfillId2 = scheduleResult[1].id; + + // ensure backfills exist + await supertest + .get( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/${backfillId1}` + ) + .set('kbn-xsrf', 'foo') + .expect(200); + + await supertest + .get( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/${backfillId2}` + ) + .set('kbn-xsrf', 'foo') + .expect(200); + + // ensure task exists + const taskRecord1 = await getScheduledTask(backfillId1); + expect(taskRecord1._source!.type).to.eql('task'); + expect(taskRecord1._source!.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord1._source!.task.timeoutOverride).to.eql('10s'); + expect(taskRecord1._source!.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord1._source!.task.params)).to.eql({ + adHocRunParamsId: backfillId1, + spaceId: apiOptions.spaceId, + }); + const taskRecord2 = await getScheduledTask(backfillId2); + expect(taskRecord2._source!.type).to.eql('task'); + expect(taskRecord2._source!.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord2._source!.task.timeoutOverride).to.eql('10s'); + expect(taskRecord2._source!.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord2._source!.task.params)).to.eql({ + adHocRunParamsId: backfillId2, + spaceId: apiOptions.spaceId, + }); + + // delete them + const deleteResponse1 = await supertestWithoutAuth + .delete( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/${backfillId1}` + ) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + const deleteResponse2 = await supertestWithoutAuth + .delete( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/${backfillId2}` + ) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + // User has read privileges in this space + case 'global_read at space1': + expect(deleteResponse1.statusCode).to.eql(403); + expect(deleteResponse1.body).to.eql({ + error: 'Forbidden', + message: `Failed to delete backfill by id: ${backfillId1}: Unauthorized by "alertsFixture" to deleteBackfill "test.patternFiringAutoRecoverFalse" rule`, + statusCode: 403, + }); + expect(deleteResponse2.statusCode).to.eql(403); + expect(deleteResponse2.body).to.eql({ + error: 'Forbidden', + message: `Failed to delete backfill by id: ${backfillId2}: Unauthorized by "alertsFixture" to deleteBackfill "test.patternFiringAutoRecoverFalse" rule`, + statusCode: 403, + }); + break; + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(deleteResponse1.statusCode).to.eql(204); + expect(deleteResponse2.statusCode).to.eql(204); + + await supertest + .get( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/${backfillId1}` + ) + .set('kbn-xsrf', 'foo') + .expect(404); + + await supertest + .get( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/${backfillId2}` + ) + .set('kbn-xsrf', 'foo') + .expect(404); + + try { + await getScheduledTask(backfillId1); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.meta.statusCode).to.eql(404); + } + + try { + await getScheduledTask(backfillId2); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.meta.statusCode).to.eql(404); + } + + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle delete request appropriately when backfill does not exist', async () => { + // get backfill as current user + const response = await supertestWithoutAuth + .delete( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/does-not-exist` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // These should all be the same 404 response + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + // User has read privileges in this space + case 'global_read at space1': + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: `Failed to delete backfill by id: does-not-exist: Saved object [ad_hoc_run_params/does-not-exist] not found`, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should not get backfill from another space', async () => { + // create rule + const rresponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId = rresponse.body.id; + objectRemover.add(apiOptions.spaceId, ruleId, 'rule', 'alerting'); + + // schedule backfill + const scheduleResponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([ + { + rule_id: ruleId, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-25T12:00:00.000Z', + }, + ]); + + const scheduleResult = scheduleResponse.body; + expect(scheduleResult.length).to.eql(1); + const backfillId = scheduleResult[0].id; + + // delete backfill as current user + const response = await supertestWithoutAuth + .delete(`${getUrlPrefix('other')}/internal/alerting/rules/backfill/${backfillId}`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // These should all be the same 404 response + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + // User has read privileges in this space + case 'global_read at space1': + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: `Failed to delete backfill by id: ${backfillId}: Saved object [ad_hoc_run_params/${backfillId}] not found`, + }); + + // backfill should still exist + await supertest + .get( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/${backfillId}` + ) + .set('kbn-xsrf', 'foo') + .expect(200); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + async function getScheduledTask(id: string): Promise> { + return await es.get({ + id: `task:${id}`, + index: '.kibana_task_manager', + }); + } + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/find.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/find.ts new file mode 100644 index 0000000000000..5f49b35a29966 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/find.ts @@ -0,0 +1,685 @@ +/* + * 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 { asyncForEach } from '@kbn/std'; +import { UserAtSpaceScenarios } from '../../../../scenarios'; +import { getTestRuleData, getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function findBackfillTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('find backfill', () => { + let backfillIds: Array<{ id: string; spaceId: string }> = []; + const objectRemover = new ObjectRemover(supertest); + + afterEach(async () => { + asyncForEach(backfillIds, async ({ id, spaceId }: { id: string; spaceId: string }) => { + await supertest + .delete(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/${id}`) + .set('kbn-xsrf', 'foo'); + }); + backfillIds = []; + await objectRemover.removeAll(); + }); + + function getRule(overwrites = {}) { + return getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: [true, false, true], + }, + }, + schedule: { interval: '12h' }, + ...overwrites, + }); + } + + function testExpectedBackfill1(data: any, id: string, ruleId: string, spaceId: string) { + expect(data.id).to.eql(id); + expect(data.duration).to.eql('12h'); + expect(data.enabled).to.eql(true); + expect(data.start).to.eql('2023-10-19T12:00:00.000Z'); + expect(data.end).to.eql('2023-10-20T12:00:00.000Z'); + expect(data.status).to.eql('pending'); + expect(data.space_id).to.eql(spaceId); + expect(typeof data.created_at).to.be('string'); + testExpectedRule(data, ruleId, false); + expect(data.schedule).to.eql([ + { + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-20T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + } + + function testExpectedBackfill2(data: any, id: string, ruleId: string, spaceId: string) { + expect(data.id).to.eql(id); + expect(data.duration).to.eql('12h'); + expect(data.enabled).to.eql(true); + expect(data.start).to.eql('2023-10-20T11:00:00.000Z'); + expect(data.end).to.eql('2023-10-20T23:00:00.000Z'); + expect(data.status).to.eql('pending'); + expect(data.space_id).to.eql(spaceId); + expect(typeof data.created_at).to.be('string'); + testExpectedRule(data, ruleId, false); + expect(data.schedule).to.eql([ + { + run_at: '2023-10-20T23:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + } + + function testExpectedRule(result: any, ruleId: string | undefined, isSO: boolean) { + if (!isSO) { + expect(result.rule.id).to.eql(ruleId); + expect(result.rule.name).to.eql('abc'); + expect(result.rule.tags).to.eql(['foo']); + expect(result.rule.params).to.eql({ + pattern: { + instance: [true, false, true], + }, + }); + expect(result.rule.enabled).to.eql(true); + expect(result.rule.consumer).to.eql('alertsFixture'); + expect(result.rule.schedule.interval).to.eql('12h'); + expect(result.rule.rule_type_id).to.eql('test.patternFiringAutoRecoverFalse'); + expect(result.rule.api_key_owner).to.eql('elastic'); + expect(result.rule.api_key_created_by_user).to.eql(false); + expect(result.rule.created_by).to.eql('elastic'); + expect(result.rule.updated_by).to.eql('elastic'); + expect(typeof result.rule.created_at).to.be('string'); + expect(typeof result.rule.updated_at).to.be('string'); + } else { + expect(result.rule.name).to.eql('abc'); + expect(result.rule.tags).to.eql(['foo']); + expect(result.rule.params).to.eql({ + pattern: { + instance: [true, false, true], + }, + }); + expect(result.rule.enabled).to.eql(true); + expect(result.rule.consumer).to.eql('alertsFixture'); + expect(result.rule.schedule.interval).to.eql('12h'); + expect(result.rule.alertTypeId).to.eql('test.patternFiringAutoRecoverFalse'); + expect(result.rule.apiKeyOwner).to.eql('elastic'); + expect(result.rule.apiKeyCreatedByUser).to.eql(false); + expect(result.rule.createdBy).to.eql('elastic'); + expect(result.rule.updatedBy).to.eql('elastic'); + expect(typeof result.rule.createdAt).to.be('string'); + expect(typeof result.rule.updatedAt).to.be('string'); + } + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + const apiOptions = { + spaceId: space.id, + username: user.username, + password: user.password, + }; + it('should handle finding backfill requests with query string appropriately', async () => { + // create 2 rules + const rresponse1 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId1 = rresponse1.body.id; + objectRemover.add(apiOptions.spaceId, ruleId1, 'rule', 'alerting'); + + const rresponse2 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId2 = rresponse2.body.id; + objectRemover.add(apiOptions.spaceId, ruleId2, 'rule', 'alerting'); + + // schedule backfill for both rules + const scheduleResponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([ + { + rule_id: ruleId1, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-20T12:00:00.000Z', + }, + { rule_id: ruleId2, start: '2023-10-20T11:00:00.000Z' }, + ]); + + const scheduleResult = scheduleResponse.body; + expect(scheduleResult.length).to.eql(2); + const backfillId1 = scheduleResult[0].id; + const backfillId2 = scheduleResult[1].id; + backfillIds.push({ id: backfillId1, spaceId: apiOptions.spaceId }); + backfillIds.push({ id: backfillId2, spaceId: apiOptions.spaceId }); + + // find backfills for rule 1 + const findRule1Response = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?rule_ids=${ruleId1}` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfills for rule 2 + const findRule2Response = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?rule_ids=${ruleId2}` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfills for both rules + const findBothRulesResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?rule_ids=${ruleId1},${ruleId2}` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfills with no query params + const findNoQueryParamsResponse = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_find`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfills for rule id that does not exist + const findNoRulesResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?rule_ids=not-a-real-rule` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill with start time that is before both backfill starts + const findWithStartBothRulesResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?start=2023-10-19T08:00:00.000Z` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill with start time that is before one backfill start + const findWithStartOneRuleResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?start=2023-10-20T08:00:00.000Z` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill with start time that is before no backfills + const findWithStartNoRulesResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?start=2023-10-21T08:00:00.000Z` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill with end time that is after both backfills ends + const findWithEndBothRulesResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?end=2023-10-21T00:00:00.000Z` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill with end time that is after one backfill ends + const findWithEndOneRuleResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?end=2023-10-20T18:00:00.000Z` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill with end time that is after no backfills + const findWithEndNoRulesResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?end=2023-10-18T00:00:00.000Z` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill for start and end time that encompasses both backfills + const findWithStartAndEndBothRulesResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?start=2023-10-19T00:00:00.000Z&end=2023-10-21T00:00:00.000Z` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill for start and end time that encompasses one backfill + const findWithStartAndEndOneRuleResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?start=2023-10-19T00:00:00.000Z&end=2023-10-20T13:00:00.000Z` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill for start and end time that encompasses no backfills + const findWithStartAndEndNoRulesResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?start=2023-10-18T00:00:00.000Z&end=2023-10-19T00:00:00.000Z` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill with sort and page, sort by start ascending and first page + const findWithSortAndPageResponse1 = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?per_page=1&page=1&sort_field=start&sort_order=asc` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill with sort and page, sort by start ascending and second page + const findWithSortAndPageResponse2 = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?per_page=1&page=2&sort_field=start&sort_order=asc` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // find backfill with sort by start descending + const findWithSortResponse = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?sort_field=start&sort_order=desc` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + [ + findRule1Response, + findRule2Response, + findBothRulesResponse, + findNoRulesResponse, + findWithStartBothRulesResponse, + findWithStartOneRuleResponse, + findWithStartNoRulesResponse, + findWithEndBothRulesResponse, + findWithEndOneRuleResponse, + findWithEndNoRulesResponse, + findWithStartAndEndBothRulesResponse, + findWithStartAndEndOneRuleResponse, + findWithStartAndEndNoRulesResponse, + findNoQueryParamsResponse, + findWithSortAndPageResponse1, + findWithSortAndPageResponse2, + findWithSortResponse, + ].forEach((response) => { + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Failed to find backfills: Unauthorized to find rules for any rule types`, + statusCode: 403, + }); + }); + break; + // User has read privileges in this space + case 'global_read at space1': + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + [ + findRule1Response, + findRule2Response, + findBothRulesResponse, + findNoRulesResponse, + findWithStartBothRulesResponse, + findWithStartOneRuleResponse, + findWithStartNoRulesResponse, + findWithEndBothRulesResponse, + findWithEndOneRuleResponse, + findWithEndNoRulesResponse, + findWithStartAndEndBothRulesResponse, + findWithStartAndEndOneRuleResponse, + findWithStartAndEndNoRulesResponse, + findNoQueryParamsResponse, + findWithSortAndPageResponse1, + findWithSortAndPageResponse2, + findWithSortResponse, + ].forEach((response) => { + expect(response.statusCode).to.eql(200); + }); + + const resultFindRule1 = findRule1Response.body; + expect(resultFindRule1.page).to.eql(1); + expect(resultFindRule1.per_page).to.eql(10); + expect(resultFindRule1.total).to.eql(1); + expect(resultFindRule1.data.length).to.eql(1); + testExpectedBackfill1(resultFindRule1.data[0], backfillId1, ruleId1, space.id); + + const resultFindRule2 = findRule2Response.body; + expect(resultFindRule2.page).to.eql(1); + expect(resultFindRule2.per_page).to.eql(10); + expect(resultFindRule2.total).to.eql(1); + expect(resultFindRule2.data.length).to.eql(1); + testExpectedBackfill2(resultFindRule2.data[0], backfillId2, ruleId2, space.id); + + const resultFindBothRules = findBothRulesResponse.body; + expect(resultFindBothRules.page).to.eql(1); + expect(resultFindBothRules.per_page).to.eql(10); + expect(resultFindBothRules.total).to.eql(2); + expect(resultFindBothRules.data.length).to.eql(2); + + testExpectedBackfill1( + resultFindBothRules.data.find((b: { id: string }) => b.id === backfillId1), + backfillId1, + ruleId1, + space.id + ); + testExpectedBackfill2( + resultFindBothRules.data.find((b: { id: string }) => b.id === backfillId2), + backfillId2, + ruleId2, + space.id + ); + + const resultFindNoRules = findNoRulesResponse.body; + expect(resultFindNoRules.page).to.eql(1); + expect(resultFindNoRules.per_page).to.eql(10); + expect(resultFindNoRules.total).to.eql(0); + expect(resultFindNoRules.data).to.eql([]); + + const resultFindWithStartBothRulesResponse = findWithStartBothRulesResponse.body; + expect(resultFindWithStartBothRulesResponse.page).to.eql(1); + expect(resultFindWithStartBothRulesResponse.per_page).to.eql(10); + expect(resultFindWithStartBothRulesResponse.total).to.eql(2); + expect(resultFindWithStartBothRulesResponse.data.length).to.eql(2); + testExpectedBackfill1( + resultFindBothRules.data.find((b: { id: string }) => b.id === backfillId1), + backfillId1, + ruleId1, + space.id + ); + testExpectedBackfill2( + resultFindBothRules.data.find((b: { id: string }) => b.id === backfillId2), + backfillId2, + ruleId2, + space.id + ); + + const resultFindWithStartOneRuleResponse = findWithStartOneRuleResponse.body; + expect(resultFindWithStartOneRuleResponse.page).to.eql(1); + expect(resultFindWithStartOneRuleResponse.per_page).to.eql(10); + expect(resultFindWithStartOneRuleResponse.total).to.eql(1); + expect(resultFindWithStartOneRuleResponse.data.length).to.eql(1); + testExpectedBackfill2( + resultFindWithStartOneRuleResponse.data[0], + backfillId2, + ruleId2, + space.id + ); + + const resultFindWithStartNoRulesResponse = findWithStartNoRulesResponse.body; + expect(resultFindWithStartNoRulesResponse.page).to.eql(1); + expect(resultFindWithStartNoRulesResponse.per_page).to.eql(10); + expect(resultFindWithStartNoRulesResponse.total).to.eql(0); + expect(resultFindWithStartNoRulesResponse.data).to.eql([]); + + const resultFindWithEndBothRulesResponse = findWithEndBothRulesResponse.body; + expect(resultFindWithEndBothRulesResponse.page).to.eql(1); + expect(resultFindWithEndBothRulesResponse.per_page).to.eql(10); + expect(resultFindWithEndBothRulesResponse.total).to.eql(2); + expect(resultFindWithEndBothRulesResponse.data.length).to.eql(2); + testExpectedBackfill1( + resultFindBothRules.data.find((b: { id: string }) => b.id === backfillId1), + backfillId1, + ruleId1, + space.id + ); + testExpectedBackfill2( + resultFindBothRules.data.find((b: { id: string }) => b.id === backfillId2), + backfillId2, + ruleId2, + space.id + ); + + const resultFindWithEndOneRuleResponse = findWithEndOneRuleResponse.body; + expect(resultFindWithEndOneRuleResponse.page).to.eql(1); + expect(resultFindWithEndOneRuleResponse.per_page).to.eql(10); + expect(resultFindWithEndOneRuleResponse.total).to.eql(1); + expect(resultFindWithEndOneRuleResponse.data.length).to.eql(1); + testExpectedBackfill1( + resultFindWithEndOneRuleResponse.data[0], + backfillId1, + ruleId1, + space.id + ); + + const resultFindWithEndNoRulesResponse = findWithEndNoRulesResponse.body; + expect(resultFindWithEndNoRulesResponse.page).to.eql(1); + expect(resultFindWithEndNoRulesResponse.per_page).to.eql(10); + expect(resultFindWithEndNoRulesResponse.total).to.eql(0); + expect(resultFindWithEndNoRulesResponse.data).to.eql([]); + + const resultFindWithStartAndEndBothRulesResponse = + findWithStartAndEndBothRulesResponse.body; + expect(resultFindWithStartAndEndBothRulesResponse.page).to.eql(1); + expect(resultFindWithStartAndEndBothRulesResponse.per_page).to.eql(10); + expect(resultFindWithStartAndEndBothRulesResponse.total).to.eql(2); + expect(resultFindWithStartAndEndBothRulesResponse.data.length).to.eql(2); + testExpectedBackfill1( + resultFindBothRules.data.find((b: { id: string }) => b.id === backfillId1), + backfillId1, + ruleId1, + space.id + ); + testExpectedBackfill2( + resultFindBothRules.data.find((b: { id: string }) => b.id === backfillId2), + backfillId2, + ruleId2, + space.id + ); + + const resultFindWithStartAndEndOneRuleResponse = + findWithStartAndEndOneRuleResponse.body; + expect(resultFindWithStartAndEndOneRuleResponse.page).to.eql(1); + expect(resultFindWithStartAndEndOneRuleResponse.per_page).to.eql(10); + expect(resultFindWithStartAndEndOneRuleResponse.total).to.eql(1); + expect(resultFindWithStartAndEndOneRuleResponse.data.length).to.eql(1); + testExpectedBackfill1( + resultFindWithStartAndEndOneRuleResponse.data[0], + backfillId1, + ruleId1, + space.id + ); + + const resultFindWithStartAndEndNoRulesResponse = + findWithStartAndEndNoRulesResponse.body; + expect(resultFindWithStartAndEndNoRulesResponse.page).to.eql(1); + expect(resultFindWithStartAndEndNoRulesResponse.per_page).to.eql(10); + expect(resultFindWithStartAndEndNoRulesResponse.total).to.eql(0); + expect(resultFindWithStartAndEndNoRulesResponse.data).to.eql([]); + + const resultFindNoQueryParams = findNoQueryParamsResponse.body; + expect(resultFindNoQueryParams.page).to.eql(1); + expect(resultFindNoQueryParams.per_page).to.eql(10); + expect(resultFindNoQueryParams.total).to.eql(2); + expect(resultFindNoQueryParams.data.length).to.eql(2); + + testExpectedBackfill1( + resultFindNoQueryParams.data.find((b: { id: string }) => b.id === backfillId1), + backfillId1, + ruleId1, + space.id + ); + testExpectedBackfill2( + resultFindNoQueryParams.data.find((b: { id: string }) => b.id === backfillId2), + backfillId2, + ruleId2, + space.id + ); + + const resultFindWithSortAndPageResponse1 = findWithSortAndPageResponse1.body; + expect(resultFindWithSortAndPageResponse1.page).to.eql(1); + expect(resultFindWithSortAndPageResponse1.per_page).to.eql(1); + expect(resultFindWithSortAndPageResponse1.total).to.eql(2); + expect(resultFindWithSortAndPageResponse1.data.length).to.eql(1); + testExpectedBackfill1( + resultFindWithSortAndPageResponse1.data[0], + backfillId1, + ruleId1, + space.id + ); + + const resultFindWithSortAndPageResponse2 = findWithSortAndPageResponse2.body; + expect(resultFindWithSortAndPageResponse2.page).to.eql(2); + expect(resultFindWithSortAndPageResponse2.per_page).to.eql(1); + expect(resultFindWithSortAndPageResponse2.total).to.eql(2); + expect(resultFindWithSortAndPageResponse2.data.length).to.eql(1); + testExpectedBackfill2( + resultFindWithSortAndPageResponse2.data[0], + backfillId2, + ruleId2, + space.id + ); + + const resultFindWithSort = findWithSortResponse.body; + expect(resultFindWithSort.page).to.eql(1); + expect(resultFindWithSort.per_page).to.eql(10); + expect(resultFindWithSort.total).to.eql(2); + expect(resultFindWithSort.data.length).to.eql(2); + + testExpectedBackfill1( + resultFindWithSort.data.find((b: { id: string }) => b.id === backfillId1), + backfillId1, + ruleId1, + space.id + ); + testExpectedBackfill2( + resultFindWithSort.data.find((b: { id: string }) => b.id === backfillId2), + backfillId2, + ruleId2, + space.id + ); + const start1 = new Date(resultFindWithSort.data[0].start).valueOf(); + const start2 = new Date(resultFindWithSort.data[1].start).valueOf(); + expect(start1).to.be.greaterThan(start2); + + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle find request with invalid query params appropriately', async () => { + // invalid start time + const response1 = await supertestWithoutAuth + .post( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_find?start=foo` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // invalid end time + const response2 = await supertestWithoutAuth + .post( + `${getUrlPrefix( + apiOptions.spaceId + )}/internal/alerting/rules/backfill/_find?start=2023-10-19T12:00:00.000Z&end=foo` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // These should all be the same 400 response because it is + // testing validation at the API level, which occurs before any + // alerting RBAC checks + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + // User has read privileges in this space + case 'global_read at space1': + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response1.statusCode).to.eql(400); + expect(response1.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: '[request query]: [start]: query start must be valid date', + }); + + expect(response2.statusCode).to.eql(400); + expect(response2.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: '[request query]: [end]: query end must be valid date', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/get.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/get.ts new file mode 100644 index 0000000000000..6a9e0a7194b5c --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/get.ts @@ -0,0 +1,373 @@ +/* + * 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 { asyncForEach } from '@kbn/std'; +import { UserAtSpaceScenarios } from '../../../../scenarios'; +import { getTestRuleData, getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function getBackfillTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('get backfill', () => { + let backfillIds: Array<{ id: string; spaceId: string }> = []; + const objectRemover = new ObjectRemover(supertest); + + afterEach(async () => { + asyncForEach(backfillIds, async ({ id, spaceId }: { id: string; spaceId: string }) => { + await supertest + .delete(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/${id}`) + .set('kbn-xsrf', 'foo'); + }); + backfillIds = []; + await objectRemover.removeAll(); + }); + + function getRule(overwrites = {}) { + return getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: [true, false, true], + }, + }, + schedule: { interval: '12h' }, + ...overwrites, + }); + } + + function testExpectedRule(result: any, ruleId: string | undefined, isSO: boolean) { + if (!isSO) { + expect(result.rule.id).to.eql(ruleId); + expect(result.rule.name).to.eql('abc'); + expect(result.rule.tags).to.eql(['foo']); + expect(result.rule.params).to.eql({ + pattern: { + instance: [true, false, true], + }, + }); + expect(result.rule.enabled).to.eql(true); + expect(result.rule.consumer).to.eql('alertsFixture'); + expect(result.rule.schedule.interval).to.eql('12h'); + expect(result.rule.rule_type_id).to.eql('test.patternFiringAutoRecoverFalse'); + expect(result.rule.api_key_owner).to.eql('elastic'); + expect(result.rule.api_key_created_by_user).to.eql(false); + expect(result.rule.created_by).to.eql('elastic'); + expect(result.rule.updated_by).to.eql('elastic'); + expect(typeof result.rule.created_at).to.be('string'); + expect(typeof result.rule.updated_at).to.be('string'); + } else { + expect(result.rule.name).to.eql('abc'); + expect(result.rule.tags).to.eql(['foo']); + expect(result.rule.params).to.eql({ + pattern: { + instance: [true, false, true], + }, + }); + expect(result.rule.enabled).to.eql(true); + expect(result.rule.consumer).to.eql('alertsFixture'); + expect(result.rule.schedule.interval).to.eql('12h'); + expect(result.rule.alertTypeId).to.eql('test.patternFiringAutoRecoverFalse'); + expect(result.rule.apiKeyOwner).to.eql('elastic'); + expect(result.rule.apiKeyCreatedByUser).to.eql(false); + expect(result.rule.createdBy).to.eql('elastic'); + expect(result.rule.updatedBy).to.eql('elastic'); + expect(typeof result.rule.createdAt).to.be('string'); + expect(typeof result.rule.updatedAt).to.be('string'); + } + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + const apiOptions = { + spaceId: space.id, + username: user.username, + password: user.password, + }; + it('should handle getting backfill job requests appropriately', async () => { + // create 2 rules + const rresponse1 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId1 = rresponse1.body.id; + objectRemover.add(apiOptions.spaceId, ruleId1, 'rule', 'alerting'); + + const rresponse2 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId2 = rresponse2.body.id; + objectRemover.add(apiOptions.spaceId, ruleId2, 'rule', 'alerting'); + + // schedule backfill for both rules + const scheduleResponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([ + { + rule_id: ruleId1, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-25T12:00:00.000Z', + }, + { rule_id: ruleId2, start: '2023-10-19T12:00:00.000Z' }, + ]); + + const scheduleResult = scheduleResponse.body; + expect(scheduleResult.length).to.eql(2); + const backfillId1 = scheduleResult[0].id; + const backfillId2 = scheduleResult[1].id; + backfillIds.push({ id: backfillId1, spaceId: apiOptions.spaceId }); + backfillIds.push({ id: backfillId2, spaceId: apiOptions.spaceId }); + + // get backfill as current user + const getResponse1 = await supertestWithoutAuth + .get( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/${backfillId1}` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + const getResponse2 = await supertestWithoutAuth + .get( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/${backfillId2}` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + expect(getResponse1.statusCode).to.eql(403); + expect(getResponse1.body).to.eql({ + error: 'Forbidden', + message: `Failed to get backfill by id: ${backfillId1}: Unauthorized by "alertsFixture" to getBackfill "test.patternFiringAutoRecoverFalse" rule`, + statusCode: 403, + }); + expect(getResponse2.statusCode).to.eql(403); + expect(getResponse2.body).to.eql({ + error: 'Forbidden', + message: `Failed to get backfill by id: ${backfillId2}: Unauthorized by "alertsFixture" to getBackfill "test.patternFiringAutoRecoverFalse" rule`, + statusCode: 403, + }); + break; + // User has read privileges in this space + case 'global_read at space1': + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(getResponse1.statusCode).to.eql(200); + expect(getResponse2.statusCode).to.eql(200); + + expect(getResponse1.body.id).to.eql(backfillId1); + expect(getResponse1.body.duration).to.eql('12h'); + expect(getResponse1.body.enabled).to.eql(true); + expect(getResponse1.body.start).to.eql('2023-10-19T12:00:00.000Z'); + expect(getResponse1.body.end).to.eql('2023-10-25T12:00:00.000Z'); + expect(getResponse1.body.status).to.eql('pending'); + expect(getResponse1.body.space_id).to.eql(space.id); + expect(typeof getResponse1.body.created_at).to.be('string'); + testExpectedRule(getResponse1.body, ruleId1, false); + expect(getResponse1.body.schedule).to.eql([ + { + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-20T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-21T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-21T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-22T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-22T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-23T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-23T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-24T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-24T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-25T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-25T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + + expect(getResponse2.body.id).to.eql(backfillId2); + expect(getResponse2.body.duration).to.eql('12h'); + expect(getResponse2.body.enabled).to.eql(true); + expect(getResponse2.body.start).to.eql('2023-10-19T12:00:00.000Z'); + expect(getResponse2.body.end).to.eql('2023-10-20T00:00:00.000Z'); + expect(getResponse2.body.status).to.eql('pending'); + expect(getResponse2.body.space_id).to.eql(space.id); + expect(typeof getResponse2.body.created_at).to.be('string'); + testExpectedRule(getResponse2.body, ruleId2, false); + expect(getResponse2.body.schedule).to.eql([ + { + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle get request appropriately when backfill does not exist', async () => { + // get backfill as current user + const response = await supertestWithoutAuth + .get( + `${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/does-not-exist` + ) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // These should all be the same 404 response + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + // User has read privileges in this space + case 'global_read at space1': + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: `Failed to get backfill by id: does-not-exist: Saved object [ad_hoc_run_params/does-not-exist] not found`, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should not get backfill from another space', async () => { + // create rule + const rresponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId = rresponse.body.id; + objectRemover.add(apiOptions.spaceId, ruleId, 'rule', 'alerting'); + + // schedule backfill + const scheduleResponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .send([ + { + rule_id: ruleId, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-25T12:00:00.000Z', + }, + ]); + + const scheduleResult = scheduleResponse.body; + expect(scheduleResult.length).to.eql(1); + const backfillId = scheduleResult[0].id; + backfillIds.push({ id: backfillId, spaceId: apiOptions.spaceId }); + + // get backfill as current user + const response = await supertestWithoutAuth + .get(`${getUrlPrefix('other')}/internal/alerting/rules/backfill/${backfillId}`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password); + + // These should all be the same 404 response + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + // User has read privileges in this space + case 'global_read at space1': + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: `Failed to get backfill by id: ${backfillId}: Saved object [ad_hoc_run_params/${backfillId}] not found`, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.ts new file mode 100644 index 0000000000000..ee2e5b9decdb7 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function backfillTests({ loadTestFile }: FtrProviderContext) { + describe('backfill rule runs', () => { + loadTestFile(require.resolve('./api_key')); + loadTestFile(require.resolve('./schedule')); + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./task_runner')); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/schedule.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/schedule.ts new file mode 100644 index 0000000000000..0f32a21d64d6d --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/schedule.ts @@ -0,0 +1,1200 @@ +/* + * 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 { ALERTING_CASES_SAVED_OBJECT_INDEX, SavedObject } from '@kbn/core-saved-objects-server'; +import { AdHocRunSO } from '@kbn/alerting-plugin/server/data/ad_hoc_run/types'; +import { get } from 'lodash'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server/saved_objects'; +import { asyncForEach } from '../../../../../../functional/services/transform/api'; +import { UserAtSpaceScenarios } from '../../../../scenarios'; +import { + checkAAD, + getTestRuleData, + getUrlPrefix, + ObjectRemover, + TaskManagerDoc, +} from '../../../../../common/lib'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function scheduleBackfillTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('schedule backfill', () => { + let backfillIds: Array<{ id: string; spaceId: string }> = []; + const objectRemover = new ObjectRemover(supertest); + + afterEach(async () => { + asyncForEach(backfillIds, async ({ id, spaceId }: { id: string; spaceId: string }) => { + await supertest + .delete(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/${id}`) + .set('kbn-xsrf', 'foo'); + }); + backfillIds = []; + await objectRemover.removeAll(); + }); + + async function getAdHocRunSO(id: string) { + const result = await es.get({ + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + id: `ad_hoc_run_params:${id}`, + }); + return result._source; + } + + async function getScheduledTask(id: string): Promise { + const scheduledTask = await es.get({ + id: `task:${id}`, + index: '.kibana_task_manager', + }); + return scheduledTask._source!; + } + + function getRule(overwrites = {}) { + return getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: [true, false, true], + }, + }, + schedule: { interval: '12h' }, + ...overwrites, + }); + } + + function getLifecycleRule(overwrites = {}) { + return getTestRuleData({ + schedule: { interval: '12h' }, + ...overwrites, + }); + } + + function testExpectedRule(result: any, ruleId: string | undefined, isSO: boolean) { + if (!isSO) { + expect(result.rule.id).to.eql(ruleId); + expect(result.rule.name).to.eql('abc'); + expect(result.rule.tags).to.eql(['foo']); + expect(result.rule.params).to.eql({ + pattern: { + instance: [true, false, true], + }, + }); + expect(result.rule.enabled).to.eql(true); + expect(result.rule.consumer).to.eql('alertsFixture'); + expect(result.rule.schedule.interval).to.eql('12h'); + expect(result.rule.rule_type_id).to.eql('test.patternFiringAutoRecoverFalse'); + expect(result.rule.api_key_owner).to.eql('elastic'); + expect(result.rule.api_key_created_by_user).to.eql(false); + expect(result.rule.created_by).to.eql('elastic'); + expect(result.rule.updated_by).to.eql('elastic'); + expect(typeof result.rule.created_at).to.be('string'); + expect(typeof result.rule.updated_at).to.be('string'); + } else { + expect(result.rule.name).to.eql('abc'); + expect(result.rule.tags).to.eql(['foo']); + expect(result.rule.params).to.eql({ + pattern: { + instance: [true, false, true], + }, + }); + expect(result.rule.enabled).to.eql(true); + expect(result.rule.consumer).to.eql('alertsFixture'); + expect(result.rule.schedule.interval).to.eql('12h'); + expect(result.rule.alertTypeId).to.eql('test.patternFiringAutoRecoverFalse'); + expect(result.rule.apiKeyOwner).to.eql('elastic'); + expect(result.rule.apiKeyCreatedByUser).to.eql(false); + expect(result.rule.createdBy).to.eql('elastic'); + expect(result.rule.updatedBy).to.eql('elastic'); + expect(typeof result.rule.createdAt).to.be('string'); + expect(typeof result.rule.updatedAt).to.be('string'); + } + } + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + const apiOptions = { + spaceId: space.id, + username: user.username, + password: user.password, + }; + it('should handle scheduling backfill job requests appropriately', async () => { + // create 2 rules + const rresponse1 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId1 = rresponse1.body.id; + objectRemover.add(apiOptions.spaceId, ruleId1, 'rule', 'alerting'); + + const rresponse2 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId2 = rresponse2.body.id; + objectRemover.add(apiOptions.spaceId, ruleId2, 'rule', 'alerting'); + + // schedule backfill for both rules as current user + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send([ + { + rule_id: ruleId1, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-25T12:00:00.000Z', + }, + { rule_id: ruleId2, start: '2023-10-19T12:00:00.000Z' }, + ]); + + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to find rules for any rule types`, + statusCode: 403, + }); + break; + // User has read privileges in this space + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized by "alertsFixture" to scheduleBackfill "test.patternFiringAutoRecoverFalse" rule`, + statusCode: 403, + }); + break; + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + const result = response.body; + + expect(result.length).to.eql(2); + expect(typeof result[0].id).to.be('string'); + backfillIds.push({ id: result[0].id, spaceId: apiOptions.spaceId }); + expect(result[0].duration).to.eql('12h'); + expect(result[0].enabled).to.eql(true); + expect(result[0].start).to.eql('2023-10-19T12:00:00.000Z'); + expect(result[0].end).to.eql('2023-10-25T12:00:00.000Z'); + expect(result[0].status).to.eql('pending'); + expect(result[0].space_id).to.eql(space.id); + expect(typeof result[0].created_at).to.be('string'); + testExpectedRule(result[0], ruleId1, false); + expect(result[0].schedule).to.eql([ + { + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-20T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-21T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-21T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-22T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-22T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-23T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-23T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-24T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-24T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-25T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + run_at: '2023-10-25T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + + expect(typeof result[1].id).to.be('string'); + backfillIds.push({ id: result[1].id, spaceId: apiOptions.spaceId }); + expect(result[1].duration).to.eql('12h'); + expect(result[1].enabled).to.eql(true); + expect(result[1].start).to.eql('2023-10-19T12:00:00.000Z'); + expect(result[1].end).to.eql('2023-10-20T00:00:00.000Z'); + expect(result[1].status).to.eql('pending'); + expect(result[1].space_id).to.eql(space.id); + expect(typeof result[1].created_at).to.be('string'); + testExpectedRule(result[1], ruleId2, false); + expect(result[1].schedule).to.eql([ + { + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + + // check that the ad hoc run SO was created + const adHocRunSO1 = (await getAdHocRunSO(result[0].id)) as SavedObject; + const adHocRun1: AdHocRunSO = get(adHocRunSO1, 'ad_hoc_run_params'); + const adHocRunSO2 = (await getAdHocRunSO(result[1].id)) as SavedObject; + const adHocRun2: AdHocRunSO = get(adHocRunSO2, 'ad_hoc_run_params'); + + expect(typeof adHocRun1.apiKeyId).to.be('string'); + expect(typeof adHocRun1.apiKeyToUse).to.be('string'); + expect(typeof adHocRun1.createdAt).to.be('string'); + expect(adHocRun1.duration).to.eql('12h'); + expect(adHocRun1.enabled).to.eql(true); + expect(adHocRun1.start).to.eql('2023-10-19T12:00:00.000Z'); + expect(adHocRun1.end).to.eql('2023-10-25T12:00:00.000Z'); + expect(adHocRun1.status).to.eql('pending'); + expect(adHocRun1.spaceId).to.eql(space.id); + testExpectedRule(adHocRun1, undefined, true); + expect(adHocRun1.schedule).to.eql([ + { + runAt: '2023-10-20T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-20T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-21T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-21T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-22T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-22T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-23T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-23T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-24T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-24T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-25T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + { + runAt: '2023-10-25T12:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + + expect(typeof adHocRun2.apiKeyId).to.be('string'); + expect(typeof adHocRun2.apiKeyToUse).to.be('string'); + expect(typeof adHocRun2.createdAt).to.be('string'); + expect(adHocRun2.duration).to.eql('12h'); + expect(adHocRun2.enabled).to.eql(true); + expect(adHocRun2.start).to.eql('2023-10-19T12:00:00.000Z'); + expect(adHocRun2.end).to.eql('2023-10-20T00:00:00.000Z'); + expect(adHocRun2.status).to.eql('pending'); + expect(adHocRun2.spaceId).to.eql(space.id); + testExpectedRule(adHocRun2, undefined, true); + expect(adHocRun2.schedule).to.eql([ + { + runAt: '2023-10-20T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + + // check references are stored correctly + expect(adHocRunSO1.references).to.eql([{ id: ruleId1, name: 'rule', type: 'alert' }]); + expect(adHocRunSO2.references).to.eql([{ id: ruleId2, name: 'rule', type: 'alert' }]); + + // check that the task was scheduled correctly + const taskRecord1 = await getScheduledTask(result[0].id); + expect(taskRecord1.type).to.eql('task'); + expect(taskRecord1.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord1.task.timeoutOverride).to.eql('10s'); + expect(taskRecord1.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord1.task.params)).to.eql({ + adHocRunParamsId: result[0].id, + spaceId: space.id, + }); + const taskRecord2 = await getScheduledTask(result[1].id); + expect(taskRecord2.type).to.eql('task'); + expect(taskRecord2.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord2.task.timeoutOverride).to.eql('10s'); + expect(taskRecord2.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord2.task.params)).to.eql({ + adHocRunParamsId: result[1].id, + spaceId: space.id, + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: result[0].id, + }); + await checkAAD({ + supertest, + spaceId: space.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: result[1].id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle scheduling multiple backfill job requests for a single rule appropriately', async () => { + // create 1 rule as current user + const rresponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId = rresponse.body.id; + objectRemover.add(apiOptions.spaceId, ruleId, 'rule', 'alerting'); + + // schedule 3 backfill jobs for rule as current user + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send([ + { + rule_id: ruleId, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-21T12:00:00.000Z', + }, + { rule_id: ruleId, start: '2023-10-18T12:00:00.000Z' }, + { + rule_id: ruleId, + start: '2023-12-30T12:00:00.000Z', + end: '2024-01-01T12:00:00.000Z', + }, + ]); + + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to find rules for any rule types`, + statusCode: 403, + }); + break; + // User has read privileges in this space + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized by "alertsFixture" to scheduleBackfill "test.patternFiringAutoRecoverFalse" rule`, + statusCode: 403, + }); + break; + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + const result = response.body; + + expect(result.length).to.eql(3); + expect(typeof result[0].id).to.be('string'); + backfillIds.push({ id: result[0].id, spaceId: apiOptions.spaceId }); + expect(result[0].duration).to.eql('12h'); + expect(result[0].enabled).to.eql(true); + expect(result[0].start).to.eql('2023-10-19T12:00:00.000Z'); + expect(result[0].end).to.eql('2023-10-21T12:00:00.000Z'); + expect(result[0].status).to.eql('pending'); + expect(result[0].space_id).to.eql(space.id); + expect(typeof result[0].created_at).to.be('string'); + testExpectedRule(result[0], ruleId, false); + expect(result[0].schedule).to.eql([ + { + interval: '12h', + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-20T12:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-21T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-21T12:00:00.000Z', + status: 'pending', + }, + ]); + + expect(typeof result[1].id).to.be('string'); + backfillIds.push({ id: result[1].id, spaceId: apiOptions.spaceId }); + expect(result[1].duration).to.eql('12h'); + expect(result[1].enabled).to.eql(true); + expect(result[1].start).to.eql('2023-10-18T12:00:00.000Z'); + expect(result[1].end).to.eql('2023-10-19T00:00:00.000Z'); + expect(result[1].status).to.eql('pending'); + expect(result[1].space_id).to.eql(space.id); + expect(typeof result[1].created_at).to.be('string'); + testExpectedRule(result[1], ruleId, false); + expect(result[1].schedule).to.eql([ + { + interval: '12h', + run_at: '2023-10-19T00:00:00.000Z', + status: 'pending', + }, + ]); + + expect(typeof result[2].id).to.be('string'); + backfillIds.push({ id: result[2].id, spaceId: apiOptions.spaceId }); + expect(result[2].duration).to.eql('12h'); + expect(result[2].enabled).to.eql(true); + expect(result[2].start).to.eql('2023-12-30T12:00:00.000Z'); + expect(result[2].end).to.eql('2024-01-01T12:00:00.000Z'); + expect(result[2].status).to.eql('pending'); + expect(result[2].space_id).to.eql(space.id); + expect(typeof result[2].created_at).to.be('string'); + testExpectedRule(result[2], ruleId, false); + expect(result[2].schedule).to.eql([ + { + interval: '12h', + run_at: '2023-12-31T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-12-31T12:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2024-01-01T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2024-01-01T12:00:00.000Z', + status: 'pending', + }, + ]); + + // check that the ad hoc run SO was created + const adHocRunSO1 = (await getAdHocRunSO(result[0].id)) as SavedObject; + const adHocRun1: AdHocRunSO = get(adHocRunSO1, 'ad_hoc_run_params'); + const adHocRunSO2 = (await getAdHocRunSO(result[1].id)) as SavedObject; + const adHocRun2: AdHocRunSO = get(adHocRunSO2, 'ad_hoc_run_params'); + const adHocRunSO3 = (await getAdHocRunSO(result[2].id)) as SavedObject; + const adHocRun3: AdHocRunSO = get(adHocRunSO3, 'ad_hoc_run_params'); + + expect(typeof adHocRun1.apiKeyId).to.be('string'); + expect(typeof adHocRun1.apiKeyToUse).to.be('string'); + expect(typeof adHocRun1.createdAt).to.be('string'); + expect(adHocRun1.duration).to.eql('12h'); + expect(adHocRun1.enabled).to.eql(true); + expect(adHocRun1.start).to.eql('2023-10-19T12:00:00.000Z'); + expect(adHocRun1.end).to.eql('2023-10-21T12:00:00.000Z'); + expect(adHocRun1.status).to.eql('pending'); + expect(adHocRun1.spaceId).to.eql(space.id); + testExpectedRule(adHocRun1, undefined, true); + expect(adHocRun1.schedule).to.eql([ + { + interval: '12h', + runAt: '2023-10-20T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + runAt: '2023-10-20T12:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + runAt: '2023-10-21T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + runAt: '2023-10-21T12:00:00.000Z', + status: 'pending', + }, + ]); + + expect(typeof adHocRun2.apiKeyId).to.be('string'); + expect(typeof adHocRun2.apiKeyToUse).to.be('string'); + expect(typeof adHocRun2.createdAt).to.be('string'); + expect(adHocRun2.duration).to.eql('12h'); + expect(adHocRun2.enabled).to.eql(true); + expect(adHocRun2.start).to.eql('2023-10-18T12:00:00.000Z'); + expect(adHocRun2.end).to.eql('2023-10-19T00:00:00.000Z'); + expect(adHocRun2.status).to.eql('pending'); + expect(adHocRun2.spaceId).to.eql(space.id); + testExpectedRule(adHocRun2, undefined, true); + expect(adHocRun2.schedule).to.eql([ + { + interval: '12h', + runAt: '2023-10-19T00:00:00.000Z', + status: 'pending', + }, + ]); + + expect(typeof adHocRun3.apiKeyId).to.be('string'); + expect(typeof adHocRun3.apiKeyToUse).to.be('string'); + expect(typeof adHocRun3.createdAt).to.be('string'); + expect(adHocRun3.duration).to.eql('12h'); + expect(adHocRun3.enabled).to.eql(true); + expect(adHocRun3.start).to.eql('2023-12-30T12:00:00.000Z'); + expect(adHocRun3.end).to.eql('2024-01-01T12:00:00.000Z'); + expect(adHocRun3.status).to.eql('pending'); + expect(adHocRun3.spaceId).to.eql(space.id); + testExpectedRule(adHocRun3, undefined, true); + expect(adHocRun3.schedule).to.eql([ + { + interval: '12h', + runAt: '2023-12-31T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + runAt: '2023-12-31T12:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + runAt: '2024-01-01T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + runAt: '2024-01-01T12:00:00.000Z', + status: 'pending', + }, + ]); + + // check references are stored correctly + expect(adHocRunSO1.references).to.eql([{ id: ruleId, name: 'rule', type: 'alert' }]); + expect(adHocRunSO2.references).to.eql([{ id: ruleId, name: 'rule', type: 'alert' }]); + expect(adHocRunSO3.references).to.eql([{ id: ruleId, name: 'rule', type: 'alert' }]); + + // check that the task was scheduled correctly + const taskRecord1 = await getScheduledTask(result[0].id); + expect(taskRecord1.type).to.eql('task'); + expect(taskRecord1.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord1.task.timeoutOverride).to.eql('10s'); + expect(taskRecord1.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord1.task.params)).to.eql({ + adHocRunParamsId: result[0].id, + spaceId: space.id, + }); + const taskRecord2 = await getScheduledTask(result[1].id); + expect(taskRecord2.type).to.eql('task'); + expect(taskRecord2.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord2.task.timeoutOverride).to.eql('10s'); + expect(taskRecord2.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord2.task.params)).to.eql({ + adHocRunParamsId: result[1].id, + spaceId: space.id, + }); + const taskRecord3 = await getScheduledTask(result[2].id); + expect(taskRecord3.type).to.eql('task'); + expect(taskRecord3.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord3.task.timeoutOverride).to.eql('10s'); + expect(taskRecord3.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord3.task.params)).to.eql({ + adHocRunParamsId: result[2].id, + spaceId: space.id, + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: result[0].id, + }); + await checkAAD({ + supertest, + spaceId: space.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: result[1].id, + }); + await checkAAD({ + supertest, + spaceId: space.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: result[2].id, + }); + + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle schedule request with invalid params appropriately', async () => { + // invalid start time + const response1 = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send([ + { + rule_id: 'abc', + start: 'foo', + }, + ]); + + // invalid end time + const response2 = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send([ + { + rule_id: 'abc', + start: '2023-10-19T12:00:00.000Z', + end: 'foo', + }, + ]); + + // end time equals start time + const response3 = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send([ + { + rule_id: 'abc', + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-19T12:00:00.000Z', + }, + ]); + + // end time is before start time + const response4 = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send([ + { + rule_id: 'abc', + start: '2023-10-19T12:00:00.000Z', + end: '2020-10-19T12:00:00.000Z', + }, + ]); + + // These should all be the same 400 response because it is + // testing validation at the API level, which occurs before any + // alerting RBAC checks + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + // User has read privileges in this space + case 'global_read at space1': + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response1.statusCode).to.eql(400); + expect(response1.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: '[request body.0]: Backfill start must be valid date', + }); + + expect(response2.statusCode).to.eql(400); + expect(response2.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: '[request body.0]: Backfill end must be valid date', + }); + + expect(response3.statusCode).to.eql(400); + expect(response3.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: '[request body.0]: Backfill end must be greater than backfill start', + }); + + expect(response4.statusCode).to.eql(400); + expect(response4.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: '[request body.0]: Backfill end must be greater than backfill start', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle schedule request with no matching rules appropriately', async () => { + // schedule backfill for non-existent rule + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send([ + { + rule_id: 'ac612b4b-5d0c-46d7-855a-98dd920e3aa6', + start: '2023-10-19T12:00:00.000Z', + }, + ]); + + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to find rules for any rule types`, + statusCode: 403, + }); + break; + // User has read privileges in this space + case 'global_read at space1': + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'No rules matching ids ac612b4b-5d0c-46d7-855a-98dd920e3aa6 found to schedule backfill', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle schedule request where some requests succeed and some requests fail appropriately', async () => { + // create 2 rules + const rresponse1 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId1 = rresponse1.body.id; + objectRemover.add(apiOptions.spaceId, ruleId1, 'rule', 'alerting'); + + const rresponse2 = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule()) + .expect(200); + const ruleId2 = rresponse2.body.id; + objectRemover.add(apiOptions.spaceId, ruleId2, 'rule', 'alerting'); + + // create lifecycle rule + const lifecycleresponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getLifecycleRule()) + .expect(200); + const lifecycleRuleId = lifecycleresponse.body.id; + objectRemover.add(apiOptions.spaceId, lifecycleRuleId, 'rule', 'alerting'); + + // create disabled rule + const disabledresponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule({ enabled: false })) + .expect(200); + const disabledRuleId = disabledresponse.body.id; + objectRemover.add(apiOptions.spaceId, disabledRuleId, 'rule', 'alerting'); + + // create rule to be deleted + const deletedresponse = await supertest + .post(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getRule({ enabled: false })) + .expect(200); + const deletedRuleId = deletedresponse.body.id; + + // delete the deleted rule + await supertest + .delete(`${getUrlPrefix(apiOptions.spaceId)}/api/alerting/rule/${deletedRuleId}`) + .set('kbn-xsrf', 'foo') + .expect(204); + + // schedule backfill as current user + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(apiOptions.spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(apiOptions.username, apiOptions.password) + .send([ + { + rule_id: ruleId1, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-21T00:00:00.000Z', + }, + { rule_id: ruleId2, start: '2023-10-19T12:00:00.000Z' }, + { rule_id: lifecycleRuleId, start: '2023-10-19T12:00:00.000Z' }, + { rule_id: disabledRuleId, start: '2023-10-19T12:00:00.000Z' }, + { rule_id: deletedRuleId, start: '2023-10-19T12:00:00.000Z' }, + { rule_id: ruleId1, start: '2023-10-19T12:00:00.000Z' }, + ]); + + switch (scenario.id) { + // User can't do anything in this space + case 'no_kibana_privileges at space1': + // User has no privileges in this space + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to find rules for any rule types`, + statusCode: 403, + }); + break; + // User has read privileges in this space + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized by "alertsFixture" to scheduleBackfill "test.patternFiringAutoRecoverFalse" rule`, + statusCode: 403, + }); + break; + // User doesn't have access to actions but that doesn't matter for backfill jobs + case 'space_1_all_alerts_none_actions at space1': + // Superuser has access to everything + case 'superuser at space1': + // User has all privileges in this space + case 'space_1_all at space1': + // User has all privileges in this space + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + const result = response.body; + + expect(result.length).to.eql(6); + + // successful schedule + expect(typeof result[0].id).to.be('string'); + backfillIds.push({ id: result[0].id, spaceId: apiOptions.spaceId }); + expect(result[0].duration).to.eql('12h'); + expect(result[0].enabled).to.eql(true); + expect(result[0].start).to.eql('2023-10-19T12:00:00.000Z'); + expect(result[0].end).to.eql('2023-10-21T00:00:00.000Z'); + expect(result[0].status).to.eql('pending'); + expect(result[0].space_id).to.eql(space.id); + expect(typeof result[0].created_at).to.be('string'); + testExpectedRule(result[0], ruleId1, false); + expect(result[0].schedule).to.eql([ + { + interval: '12h', + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-20T12:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-21T00:00:00.000Z', + status: 'pending', + }, + ]); + + // successful schedule + expect(typeof result[1].id).to.be('string'); + backfillIds.push({ id: result[1].id, spaceId: apiOptions.spaceId }); + expect(result[1].duration).to.eql('12h'); + expect(result[1].enabled).to.eql(true); + expect(result[1].start).to.eql('2023-10-19T12:00:00.000Z'); + expect(result[1].end).to.eql('2023-10-20T00:00:00.000Z'); + expect(result[1].status).to.eql('pending'); + expect(result[1].space_id).to.eql(space.id); + expect(typeof result[1].created_at).to.be('string'); + testExpectedRule(result[1], ruleId2, false); + expect(result[1].schedule).to.eql([ + { + interval: '12h', + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + }, + ]); + + // error scheduling due to unsupported rule type + expect(result[2]).to.eql({ + error: { + error: 'Bad Request', + message: `Rule type "test.noop" for rule ${lifecycleRuleId} is not supported`, + }, + }); + + // error scheduling due to disabled rule + expect(result[3]).to.eql({ + error: { + error: 'Bad Request', + message: `Rule ${disabledRuleId} is disabled`, + }, + }); + + // error scheduling due to deleted rule + expect(result[4]).to.eql({ + error: { + error: 'Not Found', + message: `Saved object [alert/${deletedRuleId}] not found`, + }, + }); + + // successful schedule + expect(typeof result[5].id).to.be('string'); + backfillIds.push({ id: result[5].id, spaceId: apiOptions.spaceId }); + expect(result[5].duration).to.eql('12h'); + expect(result[5].enabled).to.eql(true); + expect(result[5].start).to.eql('2023-10-19T12:00:00.000Z'); + expect(result[5].end).to.eql('2023-10-20T00:00:00.000Z'); + expect(result[5].status).to.eql('pending'); + expect(result[5].space_id).to.eql(space.id); + expect(typeof result[5].created_at).to.be('string'); + testExpectedRule(result[5], ruleId1, false); + expect(result[5].schedule).to.eql([ + { + interval: '12h', + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + }, + ]); + + // check that the expected ad hoc run SOs were created + const adHocRunSO1 = (await getAdHocRunSO(result[0].id)) as SavedObject; + const adHocRun1: AdHocRunSO = get(adHocRunSO1, 'ad_hoc_run_params'); + const adHocRunSO2 = (await getAdHocRunSO(result[1].id)) as SavedObject; + const adHocRun2: AdHocRunSO = get(adHocRunSO2, 'ad_hoc_run_params'); + const adHocRunSO3 = (await getAdHocRunSO(result[5].id)) as SavedObject; + const adHocRun3: AdHocRunSO = get(adHocRunSO3, 'ad_hoc_run_params'); + + expect(typeof adHocRun1.apiKeyId).to.be('string'); + expect(typeof adHocRun1.apiKeyToUse).to.be('string'); + expect(typeof adHocRun1.createdAt).to.be('string'); + expect(adHocRun1.duration).to.eql('12h'); + expect(adHocRun1.enabled).to.eql(true); + expect(adHocRun1.start).to.eql('2023-10-19T12:00:00.000Z'); + expect(adHocRun1.end).to.eql('2023-10-21T00:00:00.000Z'); + expect(adHocRun1.status).to.eql('pending'); + expect(adHocRun1.spaceId).to.eql(space.id); + testExpectedRule(adHocRun1, undefined, true); + expect(adHocRun1.schedule).to.eql([ + { + interval: '12h', + runAt: '2023-10-20T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + runAt: '2023-10-20T12:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + runAt: '2023-10-21T00:00:00.000Z', + status: 'pending', + }, + ]); + + expect(typeof adHocRun2.apiKeyId).to.be('string'); + expect(typeof adHocRun2.apiKeyToUse).to.be('string'); + expect(typeof adHocRun2.createdAt).to.be('string'); + expect(adHocRun2.duration).to.eql('12h'); + expect(adHocRun2.enabled).to.eql(true); + expect(adHocRun2.start).to.eql('2023-10-19T12:00:00.000Z'); + expect(adHocRun2.end).to.eql('2023-10-20T00:00:00.000Z'); + expect(adHocRun2.status).to.eql('pending'); + expect(adHocRun2.spaceId).to.eql(space.id); + testExpectedRule(adHocRun2, undefined, true); + expect(adHocRun2.schedule).to.eql([ + { + runAt: '2023-10-20T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + + expect(typeof adHocRun3.apiKeyId).to.be('string'); + expect(typeof adHocRun3.apiKeyToUse).to.be('string'); + expect(typeof adHocRun3.createdAt).to.be('string'); + expect(adHocRun3.duration).to.eql('12h'); + expect(adHocRun3.enabled).to.eql(true); + expect(adHocRun3.start).to.eql('2023-10-19T12:00:00.000Z'); + expect(adHocRun3.end).to.eql('2023-10-20T00:00:00.000Z'); + expect(adHocRun3.status).to.eql('pending'); + expect(adHocRun3.spaceId).to.eql(space.id); + testExpectedRule(adHocRun3, undefined, true); + expect(adHocRun3.schedule).to.eql([ + { + runAt: '2023-10-20T00:00:00.000Z', + status: 'pending', + interval: '12h', + }, + ]); + + // check references are stored correctly + expect(adHocRunSO1.references).to.eql([{ id: ruleId1, name: 'rule', type: 'alert' }]); + expect(adHocRunSO2.references).to.eql([{ id: ruleId2, name: 'rule', type: 'alert' }]); + expect(adHocRunSO3.references).to.eql([{ id: ruleId1, name: 'rule', type: 'alert' }]); + + // check that the task was scheduled correctly + const taskRecord1 = await getScheduledTask(result[0].id); + expect(taskRecord1.type).to.eql('task'); + expect(taskRecord1.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord1.task.timeoutOverride).to.eql('10s'); + expect(taskRecord1.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord1.task.params)).to.eql({ + adHocRunParamsId: result[0].id, + spaceId: space.id, + }); + const taskRecord2 = await getScheduledTask(result[1].id); + expect(taskRecord2.type).to.eql('task'); + expect(taskRecord2.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord2.task.timeoutOverride).to.eql('10s'); + expect(taskRecord2.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord2.task.params)).to.eql({ + adHocRunParamsId: result[1].id, + spaceId: space.id, + }); + const taskRecord3 = await getScheduledTask(result[5].id); + expect(taskRecord3.type).to.eql('task'); + expect(taskRecord3.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord3.task.timeoutOverride).to.eql('10s'); + expect(taskRecord3.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord3.task.params)).to.eql({ + adHocRunParamsId: result[5].id, + spaceId: space.id, + }); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: result[0].id, + }); + await checkAAD({ + supertest, + spaceId: space.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: result[1].id, + }); + await checkAAD({ + supertest, + spaceId: space.id, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: result[5].id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner.ts new file mode 100644 index 0000000000000..8aed8bb12e360 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner.ts @@ -0,0 +1,768 @@ +/* + * 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 { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { SecurityAlert } from '@kbn/alerts-as-data-utils'; +import { + ALERT_LAST_DETECTED, + ALERT_RULE_CATEGORY, + ALERT_RULE_CONSUMER, + ALERT_RULE_EXECUTION_TIMESTAMP, + ALERT_RULE_EXECUTION_UUID, + ALERT_RULE_NAME, + ALERT_RULE_PRODUCER, + ALERT_RULE_TYPE_ID, + ALERT_RULE_UUID, + ALERT_START, + ALERT_STATUS, + ALERT_WORKFLOW_STATUS, + EVENT_KIND, + SPACE_IDS, + TIMESTAMP, +} from '@kbn/rule-data-utils'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; +import { + AD_HOC_RUN_SAVED_OBJECT_TYPE, + RULE_SAVED_OBJECT_TYPE, +} from '@kbn/alerting-plugin/server/saved_objects'; +import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_maps/field_names'; +import { + createEsDocument, + DOCUMENT_REFERENCE, + DOCUMENT_SOURCE, +} from '../../../../../spaces_only/tests/alerting/create_test_data'; +import { asyncForEach } from '../../../../../../functional/services/transform/api'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { SuperuserAtSpace1 } from '../../../../scenarios'; +import { + getEventLog, + getTestRuleData, + getUrlPrefix, + ObjectRemover, + TaskManagerDoc, +} from '../../../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createBackfillTaskRunnerTests({ getService }: FtrProviderContext) { + const es = getService('es'); + const retry = getService('retry'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertest = getService('supertest'); + const objectRemover = new ObjectRemover(supertest); + + const alertsAsDataIndex = '.alerts-security.alerts-space1'; + const timestampPattern = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + const originalDocTimestamps = [ + // before first backfill run + '2023-10-18T10:42:37.452Z', + + // backfill execution set 1 + '2023-10-19T12:23:54.485Z', + '2023-10-19T13:48:11.654Z', + '2023-10-19T21:00:03.472Z', + + // backfill execution set 2 + '2023-10-20T08:12:34.954Z', + + // backfill execution set 3 + '2023-10-20T14:39:41.457Z', + '2023-10-20T14:39:41.457Z', + '2023-10-20T16:21:01.004Z', + '2023-10-20T19:02:12.475Z', + '2023-10-20T23:59:59.999Z', + + // backfill execution set 4 purposely left empty + + // after last backfill + '2023-10-21T13:36:13.175Z', + '2023-10-21T15:42:31.145Z', + ]; + + describe('ad hoc backfill task', () => { + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + }); + afterEach(async () => { + objectRemover.removeAll(); + await esTestIndexTool.destroy(); + }); + after(async () => { + await es.deleteByQuery({ + index: alertsAsDataIndex, + query: { match_all: {} }, + conflicts: 'proceed', + }); + }); + + // This test + // - indexes some documents in the test index with specific timestamps + // - creates a siem.queryRule to query the test index + // - schedules a backfill for the siem.queryRule + // - checks that the expected alerts are generated in the alerts as data index + // - checks that the timestamps in the alerts are as expected + // - checks that the expected event log documents are written for the backfill + it('should run all execution sets of a scheduled backfill and correctly generate alerts', async () => { + const spaceId = SuperuserAtSpace1.space.id; + + // Index documents + await indexTestDocs(); + + // Create siem.queryRule + const response1 = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send({ + enabled: true, + name: 'test siem query rule', + tags: [], + rule_type_id: 'siem.queryRule', + consumer: 'siem', + schedule: { interval: '12h' }, + actions: [], + params: { + author: [], + description: 'test', + falsePositives: [], + from: 'now-86460s', + ruleId: '31c54f10-9d3b-45a8-b064-b92e8c6fcbe7', + immutable: false, + license: '', + outputIndex: '', + meta: { + from: '1m', + kibana_siem_app_url: 'https://localhost:5601/app/security', + }, + maxSignals: 100, + riskScore: 21, + riskScoreMapping: [], + severity: 'low', + severityMapping: [], + threat: [], + to: 'now', + references: [], + version: 1, + exceptionsList: [], + relatedIntegrations: [], + requiredFields: [], + setup: '', + type: 'query', + language: 'kuery', + index: [ES_TEST_INDEX_NAME], + query: `source:${DOCUMENT_SOURCE}`, + filters: [], + }, + }) + .expect(200); + const ruleId = response1.body.id; + objectRemover.add(spaceId, ruleId, 'rule', 'alerting'); + + // Schedule backfill for this rule + const response2 = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send([ + { + rule_id: ruleId, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-21T12:00:00.000Z', + }, + ]) + .expect(200); + + const scheduleResult = response2.body; + + expect(scheduleResult.length).to.eql(1); + expect(scheduleResult[0].schedule.length).to.eql(4); + expect(scheduleResult[0].schedule).to.eql([ + { + interval: '12h', + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-20T12:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-21T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-21T12:00:00.000Z', + status: 'pending', + }, + ]); + + const backfillId = scheduleResult[0].id; + + // check that the task was scheduled correctly + const taskRecord = await getScheduledTask(backfillId); + expect(taskRecord.type).to.eql('task'); + expect(taskRecord.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord.task.timeoutOverride).to.eql('5m'); + expect(taskRecord.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord.task.params)).to.eql({ + adHocRunParamsId: backfillId, + spaceId, + }); + + // get the execute-backfill events + const events: IValidatedEvent[] = await waitForEventLogDocs( + backfillId, + spaceId, + new Map([['execute-backfill', { equal: 4 }]]) + ); + + // each execute-backfill event should have these fields + for (const e of events) { + expect(e?.event?.provider).to.eql('alerting'); + expect(e?.event?.action).to.eql('execute-backfill'); + expect(e?.event?.outcome).to.eql('success'); + expect(e?.['@timestamp']).to.match(timestampPattern); + expect(e?.event?.start).to.match(timestampPattern); + expect(e?.event?.end).to.match(timestampPattern); + expect(e?.rule?.id).to.eql(ruleId); + expect(e?.rule?.category).to.eql('siem.queryRule'); + expect(e?.rule?.license).to.eql('basic'); + expect(e?.rule?.ruleset).to.eql('siem'); + expect(e?.rule?.name).to.eql('test siem query rule'); + expect(e?.kibana?.alert?.rule?.consumer).to.eql('siem'); + expect(e?.kibana?.alert?.rule?.rule_type_id).to.eql('siem.queryRule'); + expect(e?.kibana?.alert?.rule?.execution?.backfill?.id).to.eql(backfillId); + expect(e?.kibana?.saved_objects).to.eql([ + { + rel: 'primary', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: backfillId, + namespace: 'space1', + }, + { + type: RULE_SAVED_OBJECT_TYPE, + id: ruleId, + type_id: 'siem.queryRule', + namespace: 'space1', + }, + ]); + expect(e?.kibana?.space_ids).to.eql(['space1']); + } + + // save the execution UUIDs + const executionUuids = events.map((e) => e?.kibana?.alert?.rule?.execution?.uuid); + + // active alert counts and backfill info will differ per backfill run + expect(events[0]?.kibana?.alert?.rule?.execution?.metrics?.alert_counts?.active).to.eql(3); + expect(events[0]?.kibana?.alert?.rule?.execution?.backfill?.start).to.eql( + scheduleResult[0].schedule[0].run_at + ); + expect(events[0]?.kibana?.alert?.rule?.execution?.backfill?.interval).to.eql( + scheduleResult[0].schedule[0].interval + ); + + expect(events[1]?.kibana?.alert?.rule?.execution?.metrics?.alert_counts?.active).to.eql(1); + expect(events[1]?.kibana?.alert?.rule?.execution?.backfill?.start).to.eql( + scheduleResult[0].schedule[1].run_at + ); + expect(events[1]?.kibana?.alert?.rule?.execution?.backfill?.interval).to.eql( + scheduleResult[0].schedule[1].interval + ); + + expect(events[2]?.kibana?.alert?.rule?.execution?.metrics?.alert_counts?.active).to.eql(5); + expect(events[2]?.kibana?.alert?.rule?.execution?.backfill?.start).to.eql( + scheduleResult[0].schedule[2].run_at + ); + expect(events[0]?.kibana?.alert?.rule?.execution?.backfill?.interval).to.eql( + scheduleResult[0].schedule[2].interval + ); + + expect(events[3]?.kibana?.alert?.rule?.execution?.metrics?.alert_counts?.active).to.eql(0); + expect(events[3]?.kibana?.alert?.rule?.execution?.backfill?.start).to.eql( + scheduleResult[0].schedule[3].run_at + ); + expect(events[0]?.kibana?.alert?.rule?.execution?.backfill?.interval).to.eql( + scheduleResult[0].schedule[3].interval + ); + + // query for alert docs + const alertDocs = await queryForAlertDocs(); + expect(alertDocs.length).to.eql(9); + + // each alert doc should have these fields + for (const alert of alertDocs) { + const source = alert._source!; + expect(source[ALERT_RULE_CATEGORY]).to.eql('Custom Query Rule'); + expect(source[ALERT_RULE_CONSUMER]).to.eql('siem'); + expect(source[ALERT_RULE_NAME]).to.eql('test siem query rule'); + expect(source[ALERT_RULE_PRODUCER]).to.eql('siem'); + expect(source[ALERT_RULE_TYPE_ID]).to.eql('siem.queryRule'); + expect(source[ALERT_RULE_UUID]).to.eql(ruleId); + expect(source[SPACE_IDS]).to.eql(['space1']); + expect(source[EVENT_KIND]).to.eql('signal'); + expect(source[ALERT_STATUS]).to.eql('active'); + expect(source[ALERT_WORKFLOW_STATUS]).to.eql('open'); + } + + // backfill run 1 alerts + const alertDocsBackfill1 = alertDocs.filter( + (alert) => alert._source![ALERT_RULE_EXECUTION_UUID] === executionUuids[0] + ); + expect(alertDocsBackfill1.length).to.eql(3); + + // check timestamps in alert docs + for (const alert of alertDocsBackfill1) { + const source = alert._source!; + expect(source[ALERT_START]).to.eql(scheduleResult[0].schedule[0].run_at); + expect(source[ALERT_LAST_DETECTED]).to.eql(scheduleResult[0].schedule[0].run_at); + expect(source[TIMESTAMP]).to.eql(scheduleResult[0].schedule[0].run_at); + expect(source[ALERT_RULE_EXECUTION_TIMESTAMP]).to.match(timestampPattern); + expect(source[ALERT_RULE_EXECUTION_TIMESTAMP]).not.to.eql( + scheduleResult[0].schedule[0].run_at + ); + } + + expect(alertDocsBackfill1[0]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[1]); + expect(alertDocsBackfill1[1]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[2]); + expect(alertDocsBackfill1[2]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[3]); + + // backfill run 2 alerts + const alertDocsBackfill2 = alertDocs.filter( + (alert) => alert._source![ALERT_RULE_EXECUTION_UUID] === executionUuids[1] + ); + expect(alertDocsBackfill2.length).to.eql(1); + + // check timestamps in alert docs + for (const alert of alertDocsBackfill2) { + const source = alert._source!; + expect(source[ALERT_START]).to.eql(scheduleResult[0].schedule[1].run_at); + expect(source[ALERT_LAST_DETECTED]).to.eql(scheduleResult[0].schedule[1].run_at); + expect(source[TIMESTAMP]).to.eql(scheduleResult[0].schedule[1].run_at); + expect(source[ALERT_RULE_EXECUTION_TIMESTAMP]).to.match(timestampPattern); + expect(source[ALERT_RULE_EXECUTION_TIMESTAMP]).not.to.eql( + scheduleResult[0].schedule[1].run_at + ); + } + + expect(alertDocsBackfill2[0]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[4]); + + // backfill run 3 alerts + const alertDocsBackfill3 = alertDocs.filter( + (alert) => alert._source![ALERT_RULE_EXECUTION_UUID] === executionUuids[2] + ); + expect(alertDocsBackfill3.length).to.eql(5); + + // check timestamps in alert docs + for (const alert of alertDocsBackfill3) { + const source = alert._source!; + expect(source[ALERT_START]).to.eql(scheduleResult[0].schedule[2].run_at); + expect(source[ALERT_LAST_DETECTED]).to.eql(scheduleResult[0].schedule[2].run_at); + expect(source[TIMESTAMP]).to.eql(scheduleResult[0].schedule[2].run_at); + expect(source[ALERT_RULE_EXECUTION_TIMESTAMP]).to.match(timestampPattern); + expect(source[ALERT_RULE_EXECUTION_TIMESTAMP]).not.to.eql( + scheduleResult[0].schedule[2].run_at + ); + } + + expect(alertDocsBackfill3[0]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[5]); + expect(alertDocsBackfill3[1]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[6]); + expect(alertDocsBackfill3[2]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[7]); + expect(alertDocsBackfill3[3]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[8]); + expect(alertDocsBackfill3[4]._source![ALERT_ORIGINAL_TIME]).to.eql(originalDocTimestamps[9]); + + // backfill run 4 alerts + const alertDocsBackfill4 = alertDocs.filter( + (alert) => alert._source![ALERT_RULE_EXECUTION_UUID] === executionUuids[3] + ); + expect(alertDocsBackfill4.length).to.eql(0); + + // task should have been deleted after backfill runs have finished + const numHits = await searchScheduledTask(backfillId); + expect(numHits).to.eql(0); + }); + + it('should handle timeouts', async () => { + const spaceId = SuperuserAtSpace1.space.id; + // create a rule that always times out + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: ['timeout'], + }, + }, + schedule: { interval: '12h' }, + }) + ) + .expect(200); + const ruleId = response.body.id; + objectRemover.add(spaceId, ruleId, 'rule', 'alerting'); + + // schedule backfill for this rule + const response2 = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send([ + { + rule_id: ruleId, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-20T12:00:00.000Z', + }, + ]) + .expect(200); + + const scheduleResult = response2.body; + + expect(scheduleResult.length).to.eql(1); + expect(scheduleResult[0].schedule.length).to.eql(2); + expect(scheduleResult[0].schedule).to.eql([ + { + interval: '12h', + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-20T12:00:00.000Z', + status: 'pending', + }, + ]); + + const backfillId = scheduleResult[0].id; + + // check that the task was scheduled correctly + const taskRecord = await getScheduledTask(backfillId); + expect(taskRecord.type).to.eql('task'); + expect(taskRecord.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord.task.timeoutOverride).to.eql('10s'); + expect(taskRecord.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord.task.params)).to.eql({ + adHocRunParamsId: backfillId, + spaceId, + }); + + // get the execute-timeout and execute-backfill events + const events: IValidatedEvent[] = await waitForEventLogDocs( + backfillId, + spaceId, + new Map([ + ['execute-timeout', { equal: 2 }], + ['execute-backfill', { equal: 2 }], + ]) + ); + + // each event log event should have these fields + const executeEvents = events.filter((e) => e?.event?.action === 'execute-backfill'); + for (const e of executeEvents) { + expect(e?.event?.provider).to.eql('alerting'); + expect(e?.event?.action).to.eql('execute-backfill'); + expect(e?.event?.outcome).to.eql('success'); + expect(e?.['@timestamp']).to.match(timestampPattern); + expect(e?.event?.start).to.match(timestampPattern); + expect(e?.event?.end).to.match(timestampPattern); + expect(e?.rule?.id).to.eql(ruleId); + expect(e?.rule?.category).to.eql('test.patternFiringAutoRecoverFalse'); + expect(e?.rule?.license).to.eql('basic'); + expect(e?.rule?.ruleset).to.eql('alertsFixture'); + expect(e?.rule?.name).to.eql('abc'); + expect(e?.kibana?.alert?.rule?.consumer).to.eql('alertsFixture'); + expect(e?.kibana?.alert?.rule?.rule_type_id).to.eql('test.patternFiringAutoRecoverFalse'); + expect(e?.kibana?.alert?.rule?.execution?.backfill?.id).to.eql(backfillId); + expect(e?.kibana?.saved_objects).to.eql([ + { + rel: 'primary', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: backfillId, + namespace: 'space1', + }, + { + type: RULE_SAVED_OBJECT_TYPE, + id: ruleId, + type_id: 'test.patternFiringAutoRecoverFalse', + namespace: 'space1', + }, + ]); + expect(e?.kibana?.space_ids).to.eql(['space1']); + } + const timeoutEvents = events.filter((e) => e?.event?.action === 'execute-timeout'); + for (const e of timeoutEvents) { + expect(e?.event?.provider).to.eql('alerting'); + expect(e?.event?.action).to.eql('execute-timeout'); + expect(e?.event?.outcome).to.be(undefined); + expect(e?.['@timestamp']).to.match(timestampPattern); + expect(e?.event?.start).to.be(undefined); + expect(e?.event?.end).to.be(undefined); + expect(e?.rule?.id).to.eql(ruleId); + expect(e?.rule?.category).to.eql('test.patternFiringAutoRecoverFalse'); + expect(e?.rule?.license).to.eql('basic'); + expect(e?.rule?.ruleset).to.eql('alertsFixture'); + expect(e?.rule?.name).to.eql('abc'); + expect(e?.kibana?.alert?.rule?.consumer).to.eql('alertsFixture'); + expect(e?.kibana?.alert?.rule?.rule_type_id).to.eql('test.patternFiringAutoRecoverFalse'); + expect(e?.kibana?.alert?.rule?.execution?.backfill?.id).to.eql(backfillId); + expect(e?.kibana?.saved_objects).to.eql([ + { + rel: 'primary', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: backfillId, + namespace: 'space1', + }, + { + type: RULE_SAVED_OBJECT_TYPE, + id: ruleId, + type_id: 'test.patternFiringAutoRecoverFalse', + namespace: 'space1', + }, + ]); + expect(e?.kibana?.space_ids).to.eql(['space1']); + expect(e?.message).to.eql(`backfill "${backfillId}" cancelled due to timeout`); + } + + // task should have been deleted after backfill runs have finished + const numHits = await searchScheduledTask(backfillId); + expect(numHits).to.eql(0); + }); + + it('should handle errors', async () => { + const spaceId = SuperuserAtSpace1.space.id; + // create a rule that always errors + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiringAutoRecoverFalse', + params: { + pattern: { + instance: ['error'], + }, + }, + schedule: { interval: '12h' }, + }) + ) + .expect(200); + const ruleId = response.body.id; + objectRemover.add(spaceId, ruleId, 'rule', 'alerting'); + + // schedule backfill for this rule + const response2 = await supertestWithoutAuth + .post(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/_schedule`) + .set('kbn-xsrf', 'foo') + .auth(SuperuserAtSpace1.user.username, SuperuserAtSpace1.user.password) + .send([ + { + rule_id: ruleId, + start: '2023-10-19T12:00:00.000Z', + end: '2023-10-21T12:00:00.000Z', + }, + ]) + .expect(200); + + const scheduleResult = response2.body; + + expect(scheduleResult.length).to.eql(1); + expect(scheduleResult[0].schedule.length).to.eql(4); + expect(scheduleResult[0].schedule).to.eql([ + { + interval: '12h', + run_at: '2023-10-20T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-20T12:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-21T00:00:00.000Z', + status: 'pending', + }, + { + interval: '12h', + run_at: '2023-10-21T12:00:00.000Z', + status: 'pending', + }, + ]); + + const backfillId = scheduleResult[0].id; + + // check that the task was scheduled correctly + const taskRecord = await getScheduledTask(backfillId); + expect(taskRecord.type).to.eql('task'); + expect(taskRecord.task.taskType).to.eql('ad_hoc_run-backfill'); + expect(taskRecord.task.timeoutOverride).to.eql('10s'); + expect(taskRecord.task.enabled).to.eql(true); + expect(JSON.parse(taskRecord.task.params)).to.eql({ + adHocRunParamsId: backfillId, + spaceId, + }); + + // get the execute-timeout and execute-backfill events + const events: IValidatedEvent[] = await waitForEventLogDocs( + backfillId, + spaceId, + new Map([['execute-backfill', { equal: 4 }]]) + ); + + // each event log event should have these fields + for (const e of events) { + expect(e?.event?.provider).to.eql('alerting'); + expect(e?.event?.action).to.eql('execute-backfill'); + expect(e?.event?.outcome).to.eql('failure'); + expect(e?.event?.reason).to.eql('execute'); + expect(e?.['@timestamp']).to.match(timestampPattern); + expect(e?.event?.start).to.match(timestampPattern); + expect(e?.event?.end).to.match(timestampPattern); + expect(e?.rule?.id).to.eql(ruleId); + expect(e?.rule?.category).to.eql('test.patternFiringAutoRecoverFalse'); + expect(e?.rule?.license).to.eql('basic'); + expect(e?.rule?.ruleset).to.eql('alertsFixture'); + expect(e?.rule?.name).to.eql('abc'); + expect(e?.kibana?.alert?.rule?.consumer).to.eql('alertsFixture'); + expect(e?.kibana?.alert?.rule?.rule_type_id).to.eql('test.patternFiringAutoRecoverFalse'); + expect(e?.kibana?.alert?.rule?.execution?.backfill?.id).to.eql(backfillId); + expect(e?.kibana?.saved_objects).to.eql([ + { + rel: 'primary', + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id: backfillId, + namespace: 'space1', + }, + { + type: RULE_SAVED_OBJECT_TYPE, + id: ruleId, + type_id: 'test.patternFiringAutoRecoverFalse', + namespace: 'space1', + }, + ]); + expect(e?.kibana?.space_ids).to.eql(['space1']); + expect(e?.kibana?.alerting?.outcome).to.eql('failure'); + expect(e?.kibana?.alerting?.status).to.eql('error'); + expect(e?.message).to.eql( + `rule execution failure: test.patternFiringAutoRecoverFalse:${ruleId}: 'abc'` + ); + expect(e?.error?.message).to.eql(`rule executor error`); + } + + // backfill info will differ per backfill run + expect(events[0]?.kibana?.alert?.rule?.execution?.backfill?.start).to.eql( + scheduleResult[0].schedule[0].run_at + ); + expect(events[0]?.kibana?.alert?.rule?.execution?.backfill?.interval).to.eql( + scheduleResult[0].schedule[0].interval + ); + expect(events[1]?.kibana?.alert?.rule?.execution?.backfill?.start).to.eql( + scheduleResult[0].schedule[1].run_at + ); + expect(events[1]?.kibana?.alert?.rule?.execution?.backfill?.interval).to.eql( + scheduleResult[0].schedule[1].interval + ); + expect(events[2]?.kibana?.alert?.rule?.execution?.backfill?.start).to.eql( + scheduleResult[0].schedule[2].run_at + ); + expect(events[2]?.kibana?.alert?.rule?.execution?.backfill?.interval).to.eql( + scheduleResult[0].schedule[2].interval + ); + expect(events[3]?.kibana?.alert?.rule?.execution?.backfill?.start).to.eql( + scheduleResult[0].schedule[3].run_at + ); + expect(events[3]?.kibana?.alert?.rule?.execution?.backfill?.interval).to.eql( + scheduleResult[0].schedule[3].interval + ); + + // task should have been deleted after backfill runs have finished + const numHits = await searchScheduledTask(backfillId); + expect(numHits).to.eql(0); + }); + + async function indexTestDocs() { + await asyncForEach(originalDocTimestamps, async (timestamp: string) => { + await createEsDocument(es, new Date(timestamp).valueOf(), 1, ES_TEST_INDEX_NAME); + }); + + await esTestIndexTool.waitForDocs( + DOCUMENT_SOURCE, + DOCUMENT_REFERENCE, + originalDocTimestamps.length + ); + } + }); + + async function queryForAlertDocs(): Promise>> { + const searchResult = await es.search({ + index: alertsAsDataIndex, + body: { query: { match_all: {} } }, + }); + return searchResult.hits.hits as Array>; + } + + async function waitForEventLogDocs( + id: string, + spaceId: string, + actions: Map + ) { + return await retry.try(async () => { + return await getEventLog({ + getService, + spaceId, + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + id, + provider: 'alerting', + actions, + }); + }); + } + + async function getScheduledTask(id: string): Promise { + const scheduledTask = await es.get({ + id: `task:${id}`, + index: '.kibana_task_manager', + }); + return scheduledTask._source!; + } + + async function searchScheduledTask(id: string) { + const searchResult = await es.search({ + index: '.kibana_task_manager', + body: { + query: { + bool: { + must: [ + { + term: { + 'task.id': `task:${id}`, + }, + }, + { + terms: { + 'task.scope': ['alerting'], + }, + }, + ], + }, + }, + }, + }); + + // @ts-expect-error + return searchResult.hits.total.value; + } +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts index 149990d5dcf89..ec938e8dc4abb 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts @@ -20,6 +20,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC await tearDown(getService); }); + loadTestFile(require.resolve('./backfill')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./find_with_post')); loadTestFile(require.resolve('./create')); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts index feba08b7d91e7..8b9d4929d5665 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/alerting/update.ts @@ -873,7 +873,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { }); it('should handle updates for a long running alert type without failing the underlying tasks due to invalidated ApiKey', async () => { - const { body: createdAlert } = await supertest + const { body: createdRule } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) .set('kbn-xsrf', 'foo') .send({ @@ -889,7 +889,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { notify_when: 'onThrottleInterval', }) .expect(200); - objectRemover.add(space.id, createdAlert.id, 'rule', 'alerting'); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); const updatedData = { name: 'bcd', tags: ['bar'], @@ -901,15 +901,23 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { throttle: '1m', notify_when: 'onThrottleInterval', }; + + // Update the rule which should invalidate the first API key const response = await supertestWithoutAuth - .put(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdAlert.id}`) + .put(`${getUrlPrefix(space.id)}/api/alerting/rule/${createdRule.id}`) .set('kbn-xsrf', 'foo') .auth(user.username, user.password) .send(updatedData); + // Invoke the invalidate API key task + await supertest + .post('/api/alerts_fixture/api_key_invalidation/_run_soon') + .set('kbn-xsrf', 'xxx') + .expect(200); + const statusUpdates: string[] = []; await retry.try(async () => { - const alertTask = (await getAlertingTaskById(createdAlert.scheduled_task_id)).docs[0]; + const alertTask = (await getAlertingTaskById(createdRule.scheduled_task_id)).docs[0]; statusUpdates.push(alertTask.status); expect(alertTask.status).to.eql('idle'); }); @@ -934,7 +942,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { case 'system_actions at space1': expect(response.statusCode).to.eql(200); await retry.try(async () => { - const alertTask = (await getAlertingTaskById(createdAlert.scheduled_task_id)) + const alertTask = (await getAlertingTaskById(createdRule.scheduled_task_id)) .docs[0]; expect(alertTask.status).to.eql('idle'); // ensure the alert is rescheduled to a minute from now diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create_test_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create_test_data.ts index d79fdd086cab9..8e6663ff1c295 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create_test_data.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create_test_data.ts @@ -93,7 +93,7 @@ export async function createEsDocumentsWithGroups({ await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, DOCUMENT_REFERENCE, totalDocuments); } -async function createEsDocument( +export async function createEsDocument( es: Client, epochMillis: number, testedValue: number, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts index 7221e8bb4190a..99464a5f4069d 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts @@ -19,6 +19,7 @@ import { ALERT_INSTANCE_ID, ALERT_RULE_CATEGORY, ALERT_RULE_CONSUMER, + ALERT_RULE_EXECUTION_TIMESTAMP, ALERT_RULE_EXECUTION_UUID, ALERT_RULE_NAME, ALERT_RULE_PARAMETERS, @@ -60,6 +61,7 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F '@timestamp', 'kibana.alert.flapping_history', 'kibana.alert.rule.execution.uuid', + 'kibana.alert.rule.execution.timestamp', ]; describe('alerts as data', () => { @@ -146,6 +148,9 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F // timestamp should be defined expect(source['@timestamp']).to.match(timestampPattern); + // execution time should be same as timestamp + expect(source[ALERT_RULE_EXECUTION_TIMESTAMP]).to.equal(source['@timestamp']); + // status should be active expect(source[ALERT_STATUS]).to.equal('active'); @@ -233,6 +238,8 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F // timestamp should be defined and not the same as prior run expect(alertADocRun2['@timestamp']).to.match(timestampPattern); expect(alertADocRun2['@timestamp']).not.to.equal(alertADocRun1['@timestamp']); + // execution time should be same as timestamp + expect(alertADocRun2[ALERT_RULE_EXECUTION_TIMESTAMP]).to.equal(alertADocRun2['@timestamp']); // status should still be active expect(alertADocRun2[ALERT_STATUS]).to.equal('active'); // flapping false, flapping history updated with additional entry @@ -266,6 +273,8 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F // timestamp should be defined and not the same as prior run expect(alertBDocRun2['@timestamp']).to.match(timestampPattern); expect(alertBDocRun2['@timestamp']).not.to.equal(alertBDocRun1['@timestamp']); + // execution time should be same as timestamp + expect(alertBDocRun2[ALERT_RULE_EXECUTION_TIMESTAMP]).to.equal(alertBDocRun2['@timestamp']); // end time should be defined expect(alertBDocRun2[ALERT_END]).to.match(timestampPattern); // status should be set to recovered @@ -303,6 +312,8 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F // timestamp should be defined and not the same as prior run expect(alertCDocRun2['@timestamp']).to.match(timestampPattern); expect(alertCDocRun2['@timestamp']).not.to.equal(alertCDocRun1['@timestamp']); + // execution time should be same as timestamp + expect(alertCDocRun2[ALERT_RULE_EXECUTION_TIMESTAMP]).to.equal(alertCDocRun2['@timestamp']); // end time should be defined expect(alertCDocRun2[ALERT_END]).to.match(timestampPattern); // status should be set to recovered @@ -372,6 +383,8 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F // timestamp should be defined and not the same as prior run expect(alertADocRun3['@timestamp']).to.match(timestampPattern); expect(alertADocRun3['@timestamp']).not.to.equal(alertADocRun2['@timestamp']); + // execution time should be same as timestamp + expect(alertADocRun3[ALERT_RULE_EXECUTION_TIMESTAMP]).to.equal(alertADocRun3['@timestamp']); // status should still be active expect(alertADocRun3[ALERT_STATUS]).to.equal('active'); // flapping false, flapping history updated with additional entry @@ -432,6 +445,8 @@ export default function createAlertsAsDataInstallResourcesTest({ getService }: F expect(alertCDocRun3[ALERT_START]).not.to.equal(alertCDocRun2[ALERT_START]); // timestamp should be defined and not the same as prior run expect(alertCDocRun3['@timestamp']).to.match(timestampPattern); + // execution time should be same as timestamp + expect(alertCDocRun3[ALERT_RULE_EXECUTION_TIMESTAMP]).to.equal(alertCDocRun3['@timestamp']); // duration should be 0 since this is a new alert expect(alertCDocRun3[ALERT_DURATION]).to.equal(0); // flapping false, flapping history should be history from prior run with additional entry diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_conflicts.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_conflicts.ts index d13d321280aae..95132afc0122c 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_conflicts.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_conflicts.ts @@ -278,6 +278,7 @@ const SkipFields = [ 'kibana.alert.duration.us', 'kibana.alert.flapping_history', 'kibana.alert.rule.execution.uuid', + 'kibana.alert.rule.execution.timestamp', // fields under our control we test separately 'runCount', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 6790888311fad..bafcb03cbe211 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -78,6 +78,7 @@ export default function ({ getService }: FtrProviderContext) { 'actions:.webhook', 'actions:.xmatters', 'actions_telemetry', + 'ad_hoc_run-backfill', 'alerting:.es-query', 'alerting:.geo-containment', 'alerting:.index-threshold', diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threat_match.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threat_match.ts index 588ac2d3ec46b..a999b430a521a 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threat_match.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/detection_engine/rule_execution_logic/trial_license_complete_tier/execution_logic/threat_match.ts @@ -136,6 +136,7 @@ function alertsAreTheSame(alertsA: any[], alertsB: any[]): void { 'kibana.alert.rule.updated_at', 'kibana.alert.rule.uuid', 'kibana.alert.rule.execution.uuid', + 'kibana.alert.rule.execution.timestamp', 'kibana.alert.start', 'kibana.alert.reason', 'kibana.alert.uuid', diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/remove_random_valued_properties_from_alert.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/remove_random_valued_properties_from_alert.ts index 3161fe1a61a6e..84659d80f7698 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/remove_random_valued_properties_from_alert.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/alerts/remove_random_valued_properties_from_alert.ts @@ -15,6 +15,7 @@ export const removeRandomValuedPropertiesFromAlert = (alert: DetectionAlert | un const { 'kibana.version': version, 'kibana.alert.rule.execution.uuid': execUuid, + 'kibana.alert.rule.execution.timestamp': execTimestamp, 'kibana.alert.rule.uuid': uuid, '@timestamp': timestamp, 'kibana.alert.rule.created_at': createdAt, diff --git a/x-pack/test_serverless/api_integration/test_suites/common/alerting/alert_documents.ts b/x-pack/test_serverless/api_integration/test_suites/common/alerting/alert_documents.ts index 2297e65d4824a..5414c9c2512ce 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/alerting/alert_documents.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/alerting/alert_documents.ts @@ -37,6 +37,7 @@ import { TAGS, VERSION, ALERT_CONSECUTIVE_MATCHES, + ALERT_RULE_EXECUTION_TIMESTAMP, } from '@kbn/rule-data-utils'; import { FtrProviderContext } from '../../../ftr_provider_context'; import { createEsQueryRule } from './helpers/alerting_api_helper'; @@ -108,6 +109,7 @@ export default function ({ getService }: FtrProviderContext) { expect(hits1[ALERT_MAINTENANCE_WINDOW_IDS]).to.be.an(Array); expect(typeof hits1[ALERT_REASON]).to.be('string'); expect(typeof hits1[ALERT_RULE_EXECUTION_UUID]).to.be('string'); + expect(typeof hits1[ALERT_RULE_EXECUTION_TIMESTAMP]).to.be('string'); expect(typeof hits1[ALERT_DURATION]).to.be('number'); expect(new Date(hits1[ALERT_START])).to.be.a(Date); expect(typeof hits1[ALERT_TIME_RANGE]).to.be('object'); @@ -115,6 +117,7 @@ export default function ({ getService }: FtrProviderContext) { expect(typeof hits1[ALERT_URL]).to.be('string'); expect(typeof hits1[VERSION]).to.be('string'); expect(typeof hits1[ALERT_CONSECUTIVE_MATCHES]).to.be('number'); + expect(hits1[ALERT_RULE_EXECUTION_TIMESTAMP]).to.eql(hits1['@timestamp']); // remove fields we aren't going to compare directly const fields = [ @@ -125,6 +128,7 @@ export default function ({ getService }: FtrProviderContext) { 'kibana.alert.maintenance_window_ids', 'kibana.alert.reason', 'kibana.alert.rule.execution.uuid', + 'kibana.alert.rule.execution.timestamp', 'kibana.alert.rule.duration', 'kibana.alert.start', 'kibana.alert.time_range', @@ -243,6 +247,7 @@ export default function ({ getService }: FtrProviderContext) { expect(hits2[EVENT_ACTION]).to.be('active'); expect(hits1[ALERT_DURATION]).to.not.be.lessThan(0); expect(hits2[ALERT_DURATION]).not.to.be(0); + expect(hits2[ALERT_RULE_EXECUTION_TIMESTAMP]).to.eql(hits2['@timestamp']); expect(hits2[ALERT_CONSECUTIVE_MATCHES]).to.be.greaterThan(hits1[ALERT_CONSECUTIVE_MATCHES]); // remove fields we know will be different @@ -253,6 +258,7 @@ export default function ({ getService }: FtrProviderContext) { 'kibana.alert.flapping_history', 'kibana.alert.reason', 'kibana.alert.rule.execution.uuid', + 'kibana.alert.rule.execution.timestamp', 'kibana.alert.consecutive_matches', ]; diff --git a/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts b/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts index e104a7d255204..4de0ef24b226a 100644 --- a/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts +++ b/x-pack/test_serverless/api_integration/test_suites/common/alerting/summary_actions.ts @@ -54,6 +54,7 @@ export default function ({ getService }: FtrProviderContext) { 'kibana.alert.maintenance_window_ids', 'kibana.alert.reason', 'kibana.alert.rule.execution.uuid', + 'kibana.alert.rule.execution.timestamp', 'kibana.alert.rule.duration', 'kibana.alert.start', 'kibana.alert.time_range',