From fef534c7e8d3bd3d468823d38e46a8ca62903674 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 22 Feb 2024 11:44:22 -0500 Subject: [PATCH 01/11] [Response Ops][Alerting] Schedule backfill API (merging into feature branch) (#176185) Towards https://github.com/elastic/kibana/issues/174355 Note that this merges into a feature branch ## Summary Adds API for scheduling backfill jobs. Other APIs such as `get`, `find` and `delete` will be added in follow-on PRs. This PR introduces 2 concepts - `ad hoc run` - This is an execution of a rule over a specific time range. I kept this terminology generic so that in the future, it could be used to support other custom rule executions (like preview rule runs). The parameters used for the `ad hoc run` are specified in a new encrypted saved object type (`ad_hoc_run_params`). This SO is encrypted because it stores the API key to use (copied from the rule) - `backfill job` - This is a specific type of `ad hoc run` that schedules a rule run for a historical time range to cover a gap in execution ### Schedule Backfill API * Only allows scheduling for persistent (not lifecycle) rule types - this is currently all detection rules * Only allows scheduling for currently enabled rules * Limits the max number of backfill jobs that can be scheduled at one time (currently limited to 10) * Checks that user has the appropriate RBAC permissions for the alerting rule types they are scheduling backfills for. This only requires `READ` permission for the rule type, which follows the same permission required to invoke the `runSoon` API * Once all permissions and pre-requisites have been validated, the API creates an `ad_hoc_run_params` saved object that is stored in the `.kibana_alerting_cases` index * Task runner to run the rule using the parameters in `ad_hoc_run_params` will be added in a follow-on PR. **Sample Request** ``` POST /internal/alerting/rules/backfill/_schedule [ { "rule_id": "abc", "start": "2023-12-30T12:00:00.000Z", "end": "2024-01-01T12:00:00.000Z", } ] ``` This would create an `ad_hoc_run_params` saved object that looks like ``` { "apiKeyId": , "apiKeyToUse": , // this is copied from the decrypted rule and then re-encrypted "createdAt": "2024-01-30T00:00:00.000Z", "duration": "12h", // uses the same schedule interval as the rule "enabled": false, "end": "2024-01-01T12:00:00.000Z", "rule": { // copied from the 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-12-30T12:00:00.000Z", "status": "pending", "schedule": [ { "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" }, ], } ``` --- docs/user/security/audit-logging.asciidoc | 4 + .../current_fields.json | 3 + .../current_mappings.json | 8 + .../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 | 1 + .../common/constants/ad_hoc_run_status.ts | 14 + .../alerting/common/constants/backfill.ts | 8 + .../alerting/common/constants/index.ts | 11 + .../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 | 58 + .../routes/backfill/response/types/latest.ts | 8 + .../routes/backfill/response/types/v1.ts | 12 + .../backfill/methods/schedule/index.ts | 8 + .../schedule/schedule_backfill.test.ts | 575 +++++++++ .../methods/schedule/schedule_backfill.ts | 149 +++ .../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 | 51 + .../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 | 152 +++ .../transform_backfill_param_to_ad_hoc_run.ts | 48 + .../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/tags/get_rule_tags.test.ts | 2 + .../authorization/alerting_authorization.ts | 1 + .../backfill_client/backfill_client.mock.ts | 17 + .../backfill_client/backfill_client.test.ts | 557 +++++++++ .../server/backfill_client/backfill_client.ts | 153 +++ .../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 | 62 + .../server/data/ad_hoc_run/types/index.ts | 8 + x-pack/plugins/alerting/server/plugin.ts | 16 +- .../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 | 58 + .../plugins/alerting/server/routes/index.ts | 6 + .../alerting/server/rules_client.mock.ts | 1 + .../rules_client/common/audit_events.ts | 7 + .../lib/add_generated_action_values.test.ts | 2 + .../lib/create_new_api_key_set.test.ts | 2 + .../server/rules_client/rules_client.ts | 5 + .../rules_client/tests/bulk_enable.test.ts | 2 + .../tests/clear_expired_snoozes.test.ts | 2 + .../server/rules_client/tests/delete.test.ts | 2 + .../server/rules_client/tests/disable.test.ts | 2 + .../server/rules_client/tests/enable.test.ts | 2 + .../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 + .../server/rules_client/tests/update.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 | 19 +- .../alerting/server/rules_client_factory.ts | 13 +- .../alerting/server/saved_objects/index.ts | 37 + x-pack/plugins/alerting/tsconfig.json | 2 +- .../alerting.test.ts | 8 + .../feature_privilege_builder/alerting.ts | 1 + .../common/plugins/aad/server/plugin.ts | 3 +- .../common/plugins/alerts/server/plugin.ts | 7 +- .../server/{alert_types.ts => rule_types.ts} | 62 +- .../group1/tests/alerting/backfill/index.ts | 15 + .../tests/alerting/backfill/schedule.ts | 1069 +++++++++++++++++ .../group1/tests/alerting/index.ts | 1 + 100 files changed, 4228 insertions(+), 42 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/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/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/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 rename x-pack/test/alerting_api_integration/common/plugins/alerts/server/{alert_types.ts => rule_types.ts} (95%) 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 diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index 268418457cc6..f93df0b0298b 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -320,6 +320,10 @@ 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+| `space_get` | `success` | User has accessed a space. | `failure` | User is not authorized to access a space. diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index fa70473b0b0a..80c74cd76cea 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -4,6 +4,9 @@ "name" ], "action_task_params": [], + "ad_hoc_run_params": [ + "createdAt" + ], "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 5bfe7f6f20de..a8caa9dc4744 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -19,6 +19,14 @@ "dynamic": false, "properties": {} }, + "ad_hoc_run_params": { + "dynamic": false, + "properties": { + "createdAt": { + "type": "date" + } + } + }, "alert": { "dynamic": false, "properties": { 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 fdf7ba4c9f42..81e10730d57d 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": "8ee6ecb4a1905dce323df07d0d228f1dd3d83195", "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 7e41abff9b02..4e46e1d4874f 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 25c548d4e0d6..a7dd80730000 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 508a70874e37..52e764b06efa 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -692,6 +692,7 @@ When a user is granted the `read` role in the Alerting Framework, they will be a - `getAlertSummary` - `getExecutionLog` - `find` +- `scheduleBackfill` When a user is granted the `all` role in the Alerting Framework, they will be able to execute all of the `read` privileged api calls, but in addition they'll be granted the following calls: diff --git a/x-pack/plugins/alerting/common/constants/ad_hoc_run_status.ts b/x-pack/plugins/alerting/common/constants/ad_hoc_run_status.ts new file mode 100644 index 000000000000..8f3c96cddb07 --- /dev/null +++ b/x-pack/plugins/alerting/common/constants/ad_hoc_run_status.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. + */ + +export const adHocRunStatus = { + 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 000000000000..0a8281cba818 --- /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 000000000000..5dc50b91a416 --- /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/schedule/index.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/schedule/index.ts new file mode 100644 index 000000000000..d570b9997601 --- /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 000000000000..25300c97a6d2 --- /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 000000000000..791a5cce9ac3 --- /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 000000000000..25300c97a6d2 --- /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 000000000000..3fe5c2989f64 --- /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 000000000000..358179c8bd7c --- /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 000000000000..25300c97a6d2 --- /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 000000000000..1c531f7aac9e --- /dev/null +++ b/x-pack/plugins/alerting/common/routes/backfill/response/schemas/v1.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { adHocRunStatus } from '../../../../constants'; + +export const statusSchema = schema.oneOf([ + 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 000000000000..25300c97a6d2 --- /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 000000000000..ff0d94f164f7 --- /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/application/backfill/methods/schedule/index.ts b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/index.ts new file mode 100644 index 000000000000..20dbcb1376d2 --- /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 000000000000..9828e7b37082 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts @@ -0,0 +1,575 @@ +/* + * 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'; + +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, + 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: [], + 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(backfillClient.bulkQueue).toHaveBeenCalledWith({ + 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, + 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, + 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 000000000000..cec56ea75d7e --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.ts @@ -0,0 +1,149 @@ +/* + * 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 { ReadOperations, AlertingAuthorizationEntity } 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: ReadOperations.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()) { + rulesToSchedule = [...response.saved_objects]; + } + + return await context.backfillClient.bulkQueue({ + 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, + }); + }), + 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 000000000000..7fe9accc9b40 --- /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 000000000000..0082181e759a --- /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 000000000000..023ab90c0ee7 --- /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 000000000000..bd7f3e4e371a --- /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 000000000000..3237e520f320 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/result/schemas/index.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 { schema } from '@kbn/config-schema'; +import { adHocRunStatus } from '../../../../../common/constants'; + +export const statusSchema = schema.oneOf([ + 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 000000000000..f62663894ca8 --- /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 000000000000..0b14236e745d --- /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 000000000000..35c2568a8074 --- /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 000000000000..219587bd0f1d --- /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 000000000000..2798bfe027a1 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.test.ts @@ -0,0 +1,152 @@ +/* + * 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, + 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 000000000000..2a8b1ca991f3 --- /dev/null +++ b/x-pack/plugins/alerting/server/application/backfill/transforms/transform_backfill_param_to_ad_hoc_run.ts @@ -0,0 +1,48 @@ +/* + * 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 => { + return { + apiKeyId: Buffer.from(rule.apiKey!, 'base64').toString().split(':')[0], + apiKeyToUse: rule.apiKey!, + createdAt: new Date().toISOString(), + duration: rule.schedule.interval, + enabled: true, + ...(param.end ? { end: param.end } : {}), + 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: calculateSchedule(param.start, rule.schedule.interval, param.end), + }; +}; 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 81c4dc8562bf..cf03d60f8be5 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 @@ -28,6 +28,7 @@ import { RecoveredActionGroup } from '../../../../../common'; import { DefaultRuleAggregationResult } from '../../../../routes/rule/apis/aggregate/types'; import { defaultRuleAggregationFactory } from '.'; import { RULE_SAVED_OBJECT_TYPE } from '../../../../saved_objects'; +import { backfillClientMock } from '../../../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -62,6 +63,7 @@ const rulesClientParams: jest.Mocked = { alertsService: null, maxScheduledPerMinute: 1000, internalSavedObjectsRepository, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 6fec4e8a636d..0401a4536bd3 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 @@ -35,6 +35,7 @@ import { } from '../../../../rules_client/tests/test_helpers'; import { migrateLegacyActions } from '../../../../rules_client/lib'; 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 { @@ -86,6 +87,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: 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 3f43d6077eb3..ad654a0544d5 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 @@ -40,6 +40,7 @@ import { } from '../../../../rules_client/tests/test_helpers'; import { migrateLegacyActions } from '../../../../rules_client/lib'; 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 { @@ -98,6 +99,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: 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 7faa85b3ef0c..9e318f1bcb07 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 @@ -37,6 +37,7 @@ import { import { migrateLegacyActions } from '../../../../rules_client/lib'; import { migrateLegacyActionsMock } from '../../../../rules_client/lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock'; 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 = { getAuthenticationAPIKey: getAuthenticationApiKeyMock, 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 78485744e810..28901b73f567 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'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -60,6 +61,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 21a7b170e287..00508f5a3b8d 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 @@ -29,6 +29,7 @@ import { RecoveredActionGroup } from '../../../../../common'; import { bulkMarkApiKeysForInvalidation } from '../../../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { getRuleExecutionStatusPending, getDefaultMonitoring } from '../../../../lib'; 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(), @@ -86,6 +87,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), 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 03f23256041b..28c478e032f4 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'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -56,6 +57,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 6ae8285d7fa3..175461bc1dd8 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 @@ -24,6 +24,7 @@ import { getBeforeSetup } from '../../../../rules_client/tests/lib'; import { RecoveredActionGroup } from '../../../../../common'; import { RegistryRuleType } from '../../../../rule_type_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(); @@ -58,6 +59,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: 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 90f4b189b119..481b74ab9bc6 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -36,6 +36,7 @@ export enum ReadOperations { GetAuthorizedAlertsIndices = 'getAuthorizedAlertsIndices', RunSoon = 'runSoon', GetRuleExecutionKPI = 'getRuleExecutionKPI', + ScheduleBackfill = 'scheduleBackfill', } export enum WriteOperations { 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 000000000000..f42cbb06f142 --- /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 000000000000..9e9a74e7b5d3 --- /dev/null +++ b/x-pack/plugins/alerting/server/backfill_client/backfill_client.test.ts @@ -0,0 +1,557 @@ +/* + * 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'; + +const logger = loggingSystemMock.create().get(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + +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, + }; +} + +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({ + 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, + }); + backfillClient = new BackfillClient({ logger }); + }); + + afterAll(() => jest.useRealTimers()); + + describe('bulkQueue()', () => { + test('should successfully create backfill saved objects', 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]; + + const mockAttributes1 = getMockAdHocRunAttributes({ + overwrites: { + start: '2023-11-16T08: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({ + 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(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', + 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({ + 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(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', + 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({ + 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(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(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({ + params: mockData, + rules: mockRules, + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + 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({ + params: mockData, + rules: [], + ruleTypeRegistry, + spaceId: 'default', + unsecuredSavedObjectsClient, + }); + + expect(unsecuredSavedObjectsClient.bulkCreate).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', + }, + }, + ]); + }); + }); +}); 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 000000000000..7419e80e929c --- /dev/null +++ b/x-pack/plugins/alerting/server/backfill_client/backfill_client.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 { + Logger, + SavedObjectReference, + SavedObjectsBulkCreateObject, + SavedObjectsClientContract, + SavedObjectsErrorHelpers, +} from '@kbn/core/server'; +import { isNumber } from 'lodash'; +import { + ScheduleBackfillError, + ScheduleBackfillParam, + ScheduleBackfillParams, + ScheduleBackfillResults, +} from '../application/backfill/methods/schedule/types'; +import { + transformBackfillParamToAdHocRun, + transformAdHocRunToBackfillResult, +} from '../application/backfill/transforms'; +import { RuleDomain } from '../application/rule/types'; +import { AdHocRunSO } from '../data/ad_hoc_run/types'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { RuleTypeRegistry } from '../types'; +import { createBackfillError } from './lib'; + +interface ConstructorOpts { + logger: Logger; +} + +interface BulkQueueOpts { + params: ScheduleBackfillParams; + rules: RuleDomain[]; + ruleTypeRegistry: RuleTypeRegistry; + spaceId: string; + unsecuredSavedObjectsClient: SavedObjectsClientContract; +} + +export class BackfillClient { + private logger: Logger; + + constructor(opts: ConstructorOpts) { + this.logger = opts.logger; + } + + public async bulkQueue({ + params, + rules, + ruleTypeRegistry, + spaceId, + unsecuredSavedObjectsClient, + }: BulkQueueOpts): Promise { + const adHocSOsToCreate: Array> = []; + const resultOrErrorMap: Map = new Map(); + + params.forEach((param: ScheduleBackfillParam, ndx: number) => { + const { rule, error } = getRuleOrError(param.ruleId, rules, ruleTypeRegistry); + if (rule) { + resultOrErrorMap.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) { + resultOrErrorMap.set(ndx, error); + this.logger.warn( + `No rule found for ruleId ${param.ruleId} - not scheduling backfill for ${JSON.stringify( + param + )}` + ); + } + }); + + if (!adHocSOsToCreate.length) { + return params.map((_, ndx: number) => resultOrErrorMap.get(ndx) as ScheduleBackfillError); + } + + const bulkCreateResponse = await unsecuredSavedObjectsClient.bulkCreate( + adHocSOsToCreate + ); + + // TODO bulk schedule the underlying tasks + const transformedResponse: ScheduleBackfillResults = bulkCreateResponse.saved_objects.map( + transformAdHocRunToBackfillResult + ); + return Array.from(resultOrErrorMap.keys()).map((ndx: number) => { + const indexOrError = resultOrErrorMap.get(ndx); + if (isNumber(indexOrError)) { + return transformedResponse[indexOrError as number]; + } else { + return indexOrError as ScheduleBackfillError; + } + }); + } +} + +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 000000000000..ceeb57371709 --- /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 000000000000..f86738c29621 --- /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 000000000000..050c19f29b1f --- /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 000000000000..b1425d7ace27 --- /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 000000000000..32623115507f --- /dev/null +++ b/x-pack/plugins/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts @@ -0,0 +1,62 @@ +/* + * 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 type AdHocRun = Omit & { id: string; rule: AdHocRunRule }; 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 000000000000..85b304ba7a02 --- /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/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 34472ed6066d..9be02bda2ade 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, @@ -101,6 +106,7 @@ import { getRulesSettingsFeature } from './rules_settings_feature'; import { maintenanceWindowFeature } from './maintenance_window_feature'; 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 = { @@ -215,6 +221,7 @@ export class AlertingPlugin { private alertsService: AlertsService | null; private pluginStop$: Subject; private dataStreamAdapter?: DataStreamAdapter; + private backfillClient?: BackfillClient; private nodeRoles: PluginInitializerContext['node']['roles']; constructor(initializerContext: PluginInitializerContext) { @@ -267,6 +274,10 @@ export class AlertingPlugin { ); } + this.backfillClient = new BackfillClient({ + logger: this.logger, + }); + this.eventLogger = plugins.eventLog.getLogger({ event: { provider: EVENT_LOG_PROVIDER }, }); @@ -462,7 +473,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) => { @@ -507,6 +518,7 @@ export class AlertingPlugin { maxScheduledPerMinute: this.config.rules.maxScheduledPerMinute, getAlertIndicesAlias: createGetAlertIndicesAliasFn(this.ruleTypeRegistry!), alertsService: this.alertsService, + backfillClient: this.backfillClient!, uiSettings: core.uiSettings, }); 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 000000000000..30545a3ac161 --- /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 000000000000..5f7e89d38ce3 --- /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 000000000000..2eab64276e02 --- /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 000000000000..25300c97a6d2 --- /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 000000000000..170d85c4f862 --- /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 000000000000..25300c97a6d2 --- /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 000000000000..ca83f4b78746 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_response/v1.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import 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'; + +export const transformResponse = ( + results: ScheduleBackfillResults +): ScheduleBackfillResponseBodyV1 => { + return results.map((result: ScheduleBackfillResult) => { + if ((result as ScheduleBackfillError)?.error) { + return result as ScheduleBackfillError; + } + + const backfillResult = result as Backfill; + const { createdAt, rule, spaceId, schedule, ...rest } = backfillResult; + + 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 74d8179111d3..cc67c5430b7c 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -66,6 +66,9 @@ 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 scheduling API +import { scheduleBackfillRoute } from './backfill/apis/schedule/schedule_backfill_route'; + export interface RouteOptions { router: IRouter; licenseState: ILicenseState; @@ -139,4 +142,7 @@ export function defineRoutes(opts: RouteOptions) { bulkUntrackAlertsByQueryRoute(router, licenseState); getQueryDelaySettingsRoute(router, licenseState); updateQueryDelaySettingsRoute(router, licenseState); + + // backfill scheduling API + scheduleBackfillRoute(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 0b4122e221ca..52031b106307 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -41,6 +41,7 @@ const createRulesClientMock = () => { getGlobalExecutionLogWithAuth: jest.fn(), getActionErrorLog: jest.fn(), getActionErrorLogWithAuth: jest.fn(), + scheduleBackfill: jest.fn(), getSpaceId: jest.fn(), bulkEdit: jest.fn(), bulkDeleteRules: jest.fn(), 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 0088f623f43b..9b19e0fc15e5 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 @@ -34,6 +34,7 @@ export enum RuleAuditAction { UNSNOOZE = 'rule_unsnooze', RUN_SOON = 'rule_run_soon', UNTRACK_ALERT = 'rule_alert_untrack', + SCHEDULE_BACKFILL = 'rule_schedule_backfill', } type VerbsTuple = [string, string, string]; @@ -83,6 +84,11 @@ 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 eventTypes: Record> = { @@ -110,6 +116,7 @@ const eventTypes: Record> = { rule_get_execution_kpi: 'access', rule_get_global_execution_kpi: 'access', rule_alert_untrack: 'change', + rule_schedule_backfill: 'access', }; export interface RuleAuditEventParams { 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 1c04ca38bf65..431dd6f77aae 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'; jest.mock('uuid', () => ({ v4: () => '111-222', @@ -61,6 +62,7 @@ describe('addGeneratedActionValues()', () => { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 44fb6d2e73c3..d2420ed01c24 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'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -55,6 +56,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 39b4353525f7..9c37f95d522f 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,8 @@ 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'; export type ConstructorOptions = Omit< RulesClientContext, @@ -181,6 +183,9 @@ export class RulesClient { public listRuleTypes = () => listRuleTypes(this.context); + public scheduleBackfill = (params: ScheduleBackfillParams) => + scheduleBackfill(this.context, params); + 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 71ec4b877859..e9f9a2e1ec32 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 @@ -39,6 +39,7 @@ import { import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { migrateLegacyActions } from '../lib'; 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 { @@ -89,6 +90,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: 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 a9673c8aa0f9..4ad0180c852a 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 @@ -27,6 +27,7 @@ import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock' import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { RuleSnooze } 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(), @@ -73,6 +74,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), 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 c7988b7644fc..40300f7cb5c2 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 @@ -26,6 +26,7 @@ import { getBeforeSetup } from './lib'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { migrateLegacyActions } from '../lib'; 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 { @@ -75,6 +76,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 9e7073da8a18..07072ae51913 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 @@ -27,6 +27,7 @@ import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { migrateLegacyActions } from '../lib'; import { migrateLegacyActionsMock } from '../lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock'; import { 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 = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 580e557e7fdf..93cc6c5267d6 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,6 +26,7 @@ import { getBeforeSetup, setGlobalDate } from './lib'; import { migrateLegacyActions } from '../lib'; import { migrateLegacyActionsMock } from '../lib/siem_legacy_actions/retrieve_migrated_legacy_actions.mock'; 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 { @@ -74,6 +75,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 2281264adaf3..7fdf1e6de41b 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 @@ -28,6 +28,7 @@ import { schema } from '@kbn/config-schema'; import { enabledRule1, enabledRule2, siemRule1, siemRule2 } from './test_helpers'; import { formatLegacyActions } from '../lib'; 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 { @@ -67,6 +68,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 e377c334d0b1..7402df11bc3a 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 @@ -25,6 +25,7 @@ import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; import { formatLegacyActions } from '../lib'; 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 { @@ -64,6 +65,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 761aca1972dd..6558cf9c8524 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 @@ -27,6 +27,7 @@ import { RawRule } from '../../types'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; 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 = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 dc20013b2ad2..9326d94e96df 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 @@ -22,6 +22,7 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { getBeforeSetup } from './lib'; 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(); @@ -56,6 +57,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 0dcc87889b8c..f744348013a4 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 @@ -27,6 +27,7 @@ import { EventsFactory } from '../../lib/alert_summary_from_event_log.test'; import { RawRule } from '../../types'; import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -61,6 +62,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 82e07adea061..c27f2405c3c6 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 @@ -28,6 +28,7 @@ import { getBeforeSetup, mockedDateString, setGlobalDate } from './lib'; import { getExecutionLogAggregation } from '../../lib/get_execution_log_aggregation'; import { fromKueryExpression } from '@kbn/es-query'; 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 = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 a9dd69f1a0e9..dae8881ee116 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'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -58,6 +59,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 fcafeea9a640..04aea8663633 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 @@ -22,6 +22,7 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -55,6 +56,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 9c06960ee91c..b10449b6506a 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 @@ -22,6 +22,7 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -55,6 +56,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 dcf28f3d0329..a16139bec7e9 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 @@ -25,6 +25,7 @@ import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; import { formatLegacyActions } from '../lib'; 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 { @@ -64,6 +65,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 2874759f98d1..4487e6cf6c63 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 @@ -23,6 +23,7 @@ import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; 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 = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 d999a7604e9b..63daf57e5b76 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 @@ -22,6 +22,7 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -55,6 +56,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 35ba9b37bfff..77306d2d2d01 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 @@ -22,6 +22,7 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { backfillClientMock } from '../../backfill_client/backfill_client.mock'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -55,6 +56,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index 7384ab467a8e..bf6a20f2c216 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -30,6 +30,7 @@ import { getBeforeSetup, setGlobalDate } from './lib'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { migrateLegacyActions } from '../lib'; 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 { @@ -94,6 +95,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; 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 b0b943cd78f6..8f0a4e2cb89f 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 @@ -23,6 +23,7 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { 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(), @@ -62,6 +63,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), uiSettings: uiSettingsServiceMock.createStartContract(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/types.ts b/x-pack/plugins/alerting/server/rules_client/types.ts index 6b3abf6fe3a3..82d1a6fab6a8 100644 --- a/x-pack/plugins/alerting/server/rules_client/types.ts +++ b/x-pack/plugins/alerting/server/rules_client/types.ts @@ -35,6 +35,7 @@ import { AlertingAuthorization } from '../authorization'; import { AlertingRulesConfig } from '../config'; import { GetAlertIndicesAlias } from '../lib'; import { AlertsService } from '../alerts_service'; +import { BackfillClient } from '../backfill_client/backfill_client'; export type { BulkEditOperation, @@ -79,6 +80,7 @@ export interface RulesClientContext { readonly getAuthenticationAPIKey: (name: string) => CreateAPIKeyResult; readonly getAlertIndicesAlias: GetAlertIndicesAlias; readonly alertsService: AlertsService | null; + readonly backfillClient: BackfillClient; 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 579eacdca377..d9ed302a31e6 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 @@ -26,6 +26,7 @@ import { RetryForConflictsAttempts } from './lib/retry_if_conflicts'; import { TaskStatus } from '@kbn/task-manager-plugin/server/task'; import { RecoveredActionGroup } from '../common'; 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(), @@ -70,6 +71,7 @@ const rulesClientParams: jest.Mocked = { getAuthenticationAPIKey: jest.fn(), getAlertIndicesAlias: jest.fn(), alertsService: null, + backfillClient: backfillClientMock.create(), 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 a0b83ca34468..e173c327d47f 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.test.ts @@ -27,7 +27,8 @@ 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 { RULE_SAVED_OBJECT_TYPE } from './saved_objects'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from './saved_objects'; +import { backfillClientMock } from './backfill_client/backfill_client.mock'; jest.mock('./rules_client'); jest.mock('./authorization/alerting_authorization'); @@ -41,6 +42,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(), @@ -59,6 +61,7 @@ const rulesClientFactoryParams: jest.Mocked = { kibanaVersion: '7.10.0', authorization: alertingAuthorizationClientFactory as unknown as AlertingAuthorizationClientFactory, + backfillClient, uiSettings: uiSettingsServiceMock.createStartContract(), }; @@ -90,7 +93,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', + AD_HOC_RUN_SAVED_OBJECT_TYPE, + ], }); expect(alertingAuthorizationClientFactory.create).toHaveBeenCalledWith(request); @@ -121,6 +128,7 @@ test('creates a rules client with proper constructor arguments when security is getAuthenticationAPIKey: expect.any(Function), getAlertIndicesAlias: expect.any(Function), alertsService: null, + backfillClient, uiSettings: rulesClientFactoryParams.uiSettings, }); }); @@ -139,7 +147,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', + AD_HOC_RUN_SAVED_OBJECT_TYPE, + ], }); expect(alertingAuthorizationClientFactory.create).toHaveBeenCalledWith(request); @@ -166,6 +178,7 @@ test('creates a rules client with proper constructor arguments', async () => { getAuthenticationAPIKey: 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 66ee93a1f4ae..609f2063cc6d 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.ts @@ -29,7 +29,8 @@ import { AlertingAuthorizationClientFactory } from './alerting_authorization_cli import { AlertingRulesConfig } from './config'; import { GetAlertIndicesAlias } from './lib'; import { AlertsService } from './alerts_service/alerts_service'; -import { RULE_SAVED_OBJECT_TYPE } from './saved_objects'; +import { BackfillClient } from './backfill_client/backfill_client'; +import { AD_HOC_RUN_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from './saved_objects'; export interface RulesClientFactoryOpts { logger: Logger; taskManager: TaskManagerStartContract; @@ -49,6 +50,7 @@ export interface RulesClientFactoryOpts { maxScheduledPerMinute: AlertingRulesConfig['maxScheduledPerMinute']; getAlertIndicesAlias: GetAlertIndicesAlias; alertsService: AlertsService | null; + backfillClient: BackfillClient; uiSettings: CoreStart['uiSettings']; } @@ -72,6 +74,7 @@ export class RulesClientFactory { private maxScheduledPerMinute!: AlertingRulesConfig['maxScheduledPerMinute']; private getAlertIndicesAlias!: GetAlertIndicesAlias; private alertsService!: AlertsService | null; + private backfillClient!: BackfillClient; private uiSettings!: CoreStart['uiSettings']; public initialize(options: RulesClientFactoryOpts) { @@ -97,6 +100,7 @@ export class RulesClientFactory { this.maxScheduledPerMinute = options.maxScheduledPerMinute; this.getAlertIndicesAlias = options.getAlertIndicesAlias; this.alertsService = options.alertsService; + this.backfillClient = options.backfillClient; this.uiSettings = options.uiSettings; } @@ -118,7 +122,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', + AD_HOC_RUN_SAVED_OBJECT_TYPE, + ], }), authorization: this.authorization.create(request), actionsAuthorization: actions.getActionsAuthorizationWithRequest(request), @@ -128,6 +136,7 @@ export class RulesClientFactory { auditLogger: securityPluginSetup?.audit.asScoped(request), getAlertIndicesAlias: this.getAlertIndicesAlias, alertsService: this.alertsService, + backfillClient: this.backfillClient, uiSettings: this.uiSettings, async getUserName() { diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 6adbc23f3108..4a35ab12fae6 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -32,6 +32,7 @@ import { import { ruleModelVersions } from './rule_model_versions'; export const RULE_SAVED_OBJECT_TYPE = 'alert'; +export const AD_HOC_RUN_SAVED_OBJECT_TYPE = 'ad_hoc_run_params'; export const RuleAttributesToEncrypt = ['apiKey']; @@ -158,6 +159,34 @@ 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: { + // shape is defined in x-pack/plugins/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts + // TODO to allow invalidate api key task to query for backfill jobs still + // using the API key + // apiKeyId: { + // type: 'keyword' + // }, + createdAt: { + type: 'date', + }, + // TODO to allow searching/filtering by status + // status: { + // type: 'keyword' + // } + }, + }, + management: { + importableAndExportable: false, + }, + }); + // Encrypted attributes encryptedSavedObjects.registerType({ type: RULE_SAVED_OBJECT_TYPE, @@ -171,4 +200,12 @@ export function setupSavedObjects( attributesToEncrypt: new Set(['apiKeyId']), attributesToIncludeInAAD: new Set(['createdAt']), }); + + // Encrypted attributes + encryptedSavedObjects.registerType({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributesToEncrypt: new Set(['apiKeyToUse']), + // attributesToIncludeInAAD: new Set(['enabled', 'start', 'duration', 'createdAt', 'spaceId', 'rule']), + attributesToExcludeFromAAD: new Set(['status', 'schedule']), + }); } diff --git a/x-pack/plugins/alerting/tsconfig.json b/x-pack/plugins/alerting/tsconfig.json index ada911639374..88e19a682eda 100644 --- a/x-pack/plugins/alerting/tsconfig.json +++ b/x-pack/plugins/alerting/tsconfig.json @@ -66,7 +66,7 @@ "@kbn/core-http-browser", "@kbn/core-saved-objects-api-server-mocks", "@kbn/core-ui-settings-server-mocks", - "@kbn/core-test-helpers-kbn-server" + "@kbn/core-test-helpers-kbn-server", ], "exclude": [ "target/**/*" 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 cd919f0725a2..87724742aa65 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 @@ -91,6 +91,7 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", "alerting:alert-type/my-feature/rule/runSoon", + "alerting:alert-type/my-feature/rule/scheduleBackfill", ] `); }); @@ -179,6 +180,7 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", "alerting:alert-type/my-feature/rule/runSoon", + "alerting:alert-type/my-feature/rule/scheduleBackfill", "alerting:alert-type/my-feature/alert/get", "alerting:alert-type/my-feature/alert/find", "alerting:alert-type/my-feature/alert/getAuthorizedAlertsIndices", @@ -227,6 +229,7 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", "alerting:alert-type/my-feature/rule/runSoon", + "alerting:alert-type/my-feature/rule/scheduleBackfill", "alerting:alert-type/my-feature/rule/create", "alerting:alert-type/my-feature/rule/delete", "alerting:alert-type/my-feature/rule/update", @@ -332,6 +335,7 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", "alerting:alert-type/my-feature/rule/runSoon", + "alerting:alert-type/my-feature/rule/scheduleBackfill", "alerting:alert-type/my-feature/rule/create", "alerting:alert-type/my-feature/rule/delete", "alerting:alert-type/my-feature/rule/update", @@ -397,6 +401,7 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", "alerting:alert-type/my-feature/rule/runSoon", + "alerting:alert-type/my-feature/rule/scheduleBackfill", "alerting:alert-type/my-feature/rule/create", "alerting:alert-type/my-feature/rule/delete", "alerting:alert-type/my-feature/rule/update", @@ -421,6 +426,7 @@ describe(`feature_privilege_builder`, () => { "alerting:readonly-alert-type/my-feature/rule/find", "alerting:readonly-alert-type/my-feature/rule/getRuleExecutionKPI", "alerting:readonly-alert-type/my-feature/rule/runSoon", + "alerting:readonly-alert-type/my-feature/rule/scheduleBackfill", ] `); }); @@ -514,6 +520,7 @@ describe(`feature_privilege_builder`, () => { "alerting:alert-type/my-feature/rule/find", "alerting:alert-type/my-feature/rule/getRuleExecutionKPI", "alerting:alert-type/my-feature/rule/runSoon", + "alerting:alert-type/my-feature/rule/scheduleBackfill", "alerting:alert-type/my-feature/rule/create", "alerting:alert-type/my-feature/rule/delete", "alerting:alert-type/my-feature/rule/update", @@ -538,6 +545,7 @@ describe(`feature_privilege_builder`, () => { "alerting:readonly-alert-type/my-feature/rule/find", "alerting:readonly-alert-type/my-feature/rule/getRuleExecutionKPI", "alerting:readonly-alert-type/my-feature/rule/runSoon", + "alerting:readonly-alert-type/my-feature/rule/scheduleBackfill", "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 871b1cfee169..30af218f1974 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 @@ -26,6 +26,7 @@ const readOperations: Record = { 'find', 'getRuleExecutionKPI', 'runSoon', + 'scheduleBackfill', ], alert: ['get', 'find', 'getAuthorizedAlertsIndices', 'getAlertSummary'], }; 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 a55417d66859..5b93bb8dfc1d 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) { +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(), @@ -1087,12 +1087,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 +1108,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 +1124,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 +1143,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 +1162,7 @@ export function defineAlertTypes( params: schema.any(), }, }; - const throwAlertType: RuleType<{}, {}, {}, {}, {}, 'default'> = { + const throwRuleType: RuleType<{}, {}, {}, {}, {}, 'default'> = { id: 'test.throw', name: 'Test: Throw', actionGroups: [ @@ -1214,7 +1214,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 +1293,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/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.ts new file mode 100644 index 000000000000..dbac7b470114 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.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. + */ + +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function backfillTests({ loadTestFile, getService }: FtrProviderContext) { + describe('backfill rule runs', () => { + loadTestFile(require.resolve('./schedule')); + }); +} 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 000000000000..272faad6056a --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/schedule.ts @@ -0,0 +1,1069 @@ +/* + * 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 { UserAtSpaceScenarios } from '../../../../scenarios'; +import { checkAAD, getTestRuleData, getUrlPrefix, ObjectRemover } 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', () => { + const objectRemover = new ObjectRemover(supertest); + + 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; + } + + 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': + // 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'); + 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'); + 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.be(undefined); + 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.be(undefined); + 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' }]); + + // 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': + // 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'); + 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'); + 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.be(undefined); + 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'); + 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.be(undefined); + 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' }]); + + // 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': + // 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'); + 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'); + 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.be(undefined); + 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'); + 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.be(undefined); + 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.be(undefined); + 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.be(undefined); + 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' }]); + + // 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/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts index 149990d5dcf8..18c2d7a1b0ba 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 @@ -34,6 +34,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./retain_api_key')); loadTestFile(require.resolve('./bulk_untrack')); loadTestFile(require.resolve('./bulk_untrack_by_query')); + loadTestFile(require.resolve('./backfill')); }); }); } From 33d57e6c8f6b13a9363f0af79c5af5ae43e6f227 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 22 Feb 2024 11:44:22 -0500 Subject: [PATCH 02/11] [Response Ops][Alerting] Schedule backfill API (merging into feature branch) (#176185) Towards https://github.com/elastic/kibana/issues/174355 Note that this merges into a feature branch ## Summary Adds API for scheduling backfill jobs. Other APIs such as `get`, `find` and `delete` will be added in follow-on PRs. This PR introduces 2 concepts - `ad hoc run` - This is an execution of a rule over a specific time range. I kept this terminology generic so that in the future, it could be used to support other custom rule executions (like preview rule runs). The parameters used for the `ad hoc run` are specified in a new encrypted saved object type (`ad_hoc_run_params`). This SO is encrypted because it stores the API key to use (copied from the rule) - `backfill job` - This is a specific type of `ad hoc run` that schedules a rule run for a historical time range to cover a gap in execution ### Schedule Backfill API * Only allows scheduling for persistent (not lifecycle) rule types - this is currently all detection rules * Only allows scheduling for currently enabled rules * Limits the max number of backfill jobs that can be scheduled at one time (currently limited to 10) * Checks that user has the appropriate RBAC permissions for the alerting rule types they are scheduling backfills for. This only requires `READ` permission for the rule type, which follows the same permission required to invoke the `runSoon` API * Once all permissions and pre-requisites have been validated, the API creates an `ad_hoc_run_params` saved object that is stored in the `.kibana_alerting_cases` index * Task runner to run the rule using the parameters in `ad_hoc_run_params` will be added in a follow-on PR. **Sample Request** ``` POST /internal/alerting/rules/backfill/_schedule [ { "rule_id": "abc", "start": "2023-12-30T12:00:00.000Z", "end": "2024-01-01T12:00:00.000Z", } ] ``` This would create an `ad_hoc_run_params` saved object that looks like ``` { "apiKeyId": , "apiKeyToUse": , // this is copied from the decrypted rule and then re-encrypted "createdAt": "2024-01-30T00:00:00.000Z", "duration": "12h", // uses the same schedule interval as the rule "enabled": false, "end": "2024-01-01T12:00:00.000Z", "rule": { // copied from the 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-12-30T12:00:00.000Z", "status": "pending", "schedule": [ { "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" }, ], } ``` --- x-pack/plugins/alerting/server/saved_objects/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 4a35ab12fae6..1af4432a2019 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -208,4 +208,12 @@ export function setupSavedObjects( // attributesToIncludeInAAD: new Set(['enabled', 'start', 'duration', 'createdAt', 'spaceId', 'rule']), attributesToExcludeFromAAD: new Set(['status', 'schedule']), }); + + // Encrypted attributes + encryptedSavedObjects.registerType({ + type: AD_HOC_RUN_SAVED_OBJECT_TYPE, + attributesToEncrypt: new Set(['apiKeyToUse']), + // attributesToIncludeInAAD: new Set(['enabled', 'start', 'duration', 'createdAt', 'spaceId', 'rule']), + attributesToExcludeFromAAD: new Set(['status', 'schedule']), + }); } From bc5ccde4c3d3bc8a6004e26db9c614b9df731b44 Mon Sep 17 00:00:00 2001 From: Ying Date: Fri, 22 Mar 2024 09:48:16 -0400 Subject: [PATCH 03/11] Fixing bad merge --- .../alerting/server/saved_objects/index.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 1af4432a2019..32cbd04db76d 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -205,15 +205,13 @@ export function setupSavedObjects( encryptedSavedObjects.registerType({ type: AD_HOC_RUN_SAVED_OBJECT_TYPE, attributesToEncrypt: new Set(['apiKeyToUse']), - // attributesToIncludeInAAD: new Set(['enabled', 'start', 'duration', 'createdAt', 'spaceId', 'rule']), - attributesToExcludeFromAAD: new Set(['status', 'schedule']), - }); - - // Encrypted attributes - encryptedSavedObjects.registerType({ - type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - attributesToEncrypt: new Set(['apiKeyToUse']), - // attributesToIncludeInAAD: new Set(['enabled', 'start', 'duration', 'createdAt', 'spaceId', 'rule']), - attributesToExcludeFromAAD: new Set(['status', 'schedule']), + attributesToIncludeInAAD: new Set([ + 'enabled', + 'start', + 'duration', + 'createdAt', + 'spaceId', + 'rule', + ]), }); } From d403de0dede508ee7c4d560e69ecbb785bfa8d49 Mon Sep 17 00:00:00 2001 From: Ying Date: Mon, 1 Apr 2024 10:25:04 -0400 Subject: [PATCH 04/11] Fixing new jest integration test --- .../core-saved-objects-base-server-internal/src/constants.ts | 1 + 1 file changed, 1 insertion(+) 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 b001229dd232..ecb8dffc6dc0 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|6718cb19f5b39b55a874cee31f859def': '10.0.0', 'alert|96a5a144778243a9f4fece0e71c2197f': '10.0.0', 'api_key_pending_invalidation|16f515278a295f6245149ad7c5ddedb7': '10.0.0', 'apm-custom-dashboards|561810b957ac3c09fcfc08f32f168e97': '10.0.0', From 4017ac5ae2a7da3104c853f34941f962d173a436 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Mon, 8 Apr 2024 11:24:56 -0400 Subject: [PATCH 05/11] [Response Ops][Alerting] Ad hoc rule task runner (merging into feature branch) (#177640) Resolves https://github.com/elastic/kibana/issues/174358 Note that this merges into a feature branch ## Summary Adds task runner for ad hoc runs of rules over an arbitrary time interval. This PR: 1. Updates the `BackfillClient` to create a task (`type:ad_hoc_run-backfill`) for each backfill job scheduled via the [schedule API](https://github.com/elastic/kibana/pull/176185) 2. Creates an `AdHocTaskRunner` to run these tasks. This task runner does the following: a. Loads and decrypts the `AdHocRun` saved object b. Determines which schedule entry from the saved object to run by looking for the next `PENDING` entry c. Uses the `runAt` time in the schedule as the `startedAt` time passed into the executors and to determine the time range returned by the `getTimeRange` function. d. Creates an `execute-backfill` event log doc that contains the ID of the `AdHocRun` saved object and the `runAt` and interval for the ad hoc run e. Updates the schedule entry in the `AdHocRun` saved object to reflect the outcome of the execution, either `success`, `timeout` or `error`. 3. Alerts created via the ad hoc task runner uses the following timestamps - `@timestamp` - this is set to the backfill `runAt` time - `kibana.alert.start` - this is set to the backfill `runAt` time - `kibana.alert.rule.execution.timestamp` - this is a new field that reflects the actual time the backfill was run. For real-time rule runs, this timestamp should match the `@timestamp` timestamp. 4. When all schedule entries for a backfill have been run, either successfully or unsuccessfully, the `ad_hoc_run-backfill` task will be deleted and the `AdHocRun` saved object will be deleted. We can use the event log entries to get the status of each backfill run, similar to what we do with action runs. We do this cleanup in order to clean up the tasks and saved objects. In the future, if we introduce ways to retry specific runs, we can change the logic to leave behind the task and/or SO where it makes sense. ## To Verify - Index some documents with timestamps in the past. - Create a detection rule that queries over the index and grab the ID of the rule. I typically choose a long schedule interval like `12h` so that the actual rule only runs once during testing. - Use the schedule backfill API to schedule a backfill. Pick a long backfill time range so it doesn't run too fast ``` POST /internal/alerting/rules/backfill/_schedule [ { "rule_id": , "start": "2023-12-30T12:00:00.000Z", "end": "2024-01-01T12:00:00.000Z", } ] ``` - Verify that a task was created for this backfill. The task should use the same ID as the backfill - Query for the `AdHocRun` SO in Dev Tools. You should be able to see the schedule entries changing status as the backfill executions are fulfilled. - Verify that the appropriate event log docs are written for the backfills. There should be one `execute-backfill` event written for each schedule entry - Verify that alerts are found and the timestamp fields for these alerts are populated correctly. --- .../src/field_maps/alert_field_map.ts | 6 + .../src/schemas/generated/alert_schema.ts | 1 + .../src/schemas/generated/security_schema.ts | 1 + .../src/default_alerts_as_data.ts | 5 + .../field_maps/mapping_from_field_map.test.ts | 3 + .../common/constants/ad_hoc_run_status.ts | 1 + .../routes/backfill/response/schemas/v1.ts | 1 + .../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 | 51 + .../lib/build_updated_recovered_alert.ts | 10 +- .../lib/initialize_alerts_client.test.ts | 84 +- .../lib/initialize_alerts_client.ts | 17 +- .../alerting/server/alerts_client/types.ts | 1 + .../backfill/result/schemas/index.ts | 1 + .../backfill_client/backfill_client.test.ts | 231 ++- .../server/backfill_client/backfill_client.ts | 120 +- .../data/ad_hoc_run/types/ad_hoc_run.ts | 15 +- .../alert_as_data_fields.test.ts.snap | 40 + .../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 | 17 +- .../alerting/server/saved_objects/index.ts | 29 +- .../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 | 6 + .../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 + ...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 +- .../plugins/alerts/server/rule_types.ts | 10 +- .../group1/tests/alerting/backfill/index.ts | 1 + .../tests/alerting/backfill/schedule.ts | 94 +- .../tests/alerting/backfill/task_runner.ts | 769 ++++++++ .../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 + 84 files changed, 6936 insertions(+), 1042 deletions(-) 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%) create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner.ts 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 48320fd29e47..54a09c67d59a 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 7d1f9304eaa3..8a5d2a56bc32 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 c57f9862f432..fd585473fe59 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-rule-data-utils/src/default_alerts_as_data.ts b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts index dfd51bf73758..ce334e5d0fc5 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/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 8718fcb2db59..ad28d03235ca 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 index 8f3c96cddb07..2249717d2c38 100644 --- a/x-pack/plugins/alerting/common/constants/ad_hoc_run_status.ts +++ b/x-pack/plugins/alerting/common/constants/ad_hoc_run_status.ts @@ -6,6 +6,7 @@ */ export const adHocRunStatus = { + COMPLETE: 'complete', PENDING: 'pending', RUNNING: 'running', ERROR: 'error', 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 index 1c531f7aac9e..8db238b89ee8 100644 --- a/x-pack/plugins/alerting/common/routes/backfill/response/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/backfill/response/schemas/v1.ts @@ -9,6 +9,7 @@ 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), 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 60e0889d9aec..3eeb0d638402 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 4fab0825354b..56a2855a912d 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: recoveredAlerts[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 47c4e9e5f4f5..280c49df36ed 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 911c0cc8c6c9..cc77099a1162 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 7e76a829d0d3..136a2b62962b 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 8d1be2e75ecb..6c6200587322 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 3b4f23ac7cb4..ebaa829c0988 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 46f36c9715e1..0f874d857736 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 1c646f08d782..1a1a328a1fe6 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, } from '@kbn/rule-data-utils'; import { alertRule, @@ -58,6 +59,55 @@ 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'], + }); + }); + + 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', @@ -129,6 +179,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 59c9a5cb3587..4d2fcbfefcca 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 @@ -7,7 +7,12 @@ import deepmerge from 'deepmerge'; import type { Alert } from '@kbn/alerts-as-data-utils'; -import { ALERT_FLAPPING, ALERT_FLAPPING_HISTORY, TIMESTAMP } from '@kbn/rule-data-utils'; +import { + ALERT_FLAPPING, + ALERT_FLAPPING_HISTORY, + ALERT_RULE_EXECUTION_TIMESTAMP, + TIMESTAMP, +} from '@kbn/rule-data-utils'; import { RawAlertInstance } from '@kbn/alerting-state-types'; import { RuleAlertData } from '../../types'; import { AlertRule } from '../types'; @@ -16,6 +21,7 @@ import { removeUnflattenedFieldsFromAlert, replaceRefreshableAlertFields } from interface BuildUpdatedRecoveredAlertOpts { alert: Alert & AlertData; legacyRawAlert: RawAlertInstance; + runTimestamp?: string; timestamp: string; rule: AlertRule; } @@ -29,6 +35,7 @@ export const buildUpdatedRecoveredAlert = ({ alert, legacyRawAlert, rule, + runTimestamp, timestamp, }: BuildUpdatedRecoveredAlertOpts): Alert & AlertData => { // Make sure that any alert fields that are updateable are flattened. @@ -39,6 +46,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 b81e369d6048..779f286686a7 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 f00348ca502c..f8c750c01050 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 7f4f9ddf4d99..18fb52a806b6 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/result/schemas/index.ts b/x-pack/plugins/alerting/server/application/backfill/result/schemas/index.ts index 3237e520f320..de3cc5926a4a 100644 --- a/x-pack/plugins/alerting/server/application/backfill/result/schemas/index.ts +++ b/x-pack/plugins/alerting/server/application/backfill/result/schemas/index.ts @@ -9,6 +9,7 @@ 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), 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 index 9e9a74e7b5d3..aa9d0d17240c 100644 --- a/x-pack/plugins/alerting/server/backfill_client/backfill_client.test.ts +++ b/x-pack/plugins/alerting/server/backfill_client/backfill_client.test.ts @@ -17,8 +17,14 @@ 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 { 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(); @@ -30,6 +36,27 @@ function getMockData(overwrites: Record = {}): ScheduleBackfill }; } +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 { @@ -152,35 +179,31 @@ describe('BackfillClient', () => { beforeEach(() => { jest.resetAllMocks(); - 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, + ruleTypeRegistry.get.mockReturnValue(mockRuleType); + backfillClient = new BackfillClient({ + logger, + taskManagerSetup, + taskManagerStartPromise: Promise.resolve(taskManagerStart), + taskRunnerFactory: new TaskRunnerFactory(), }); - backfillClient = new BackfillClient({ logger }); }); 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', async () => { + 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' }), @@ -188,6 +211,7 @@ describe('BackfillClient', () => { const rule1 = getMockRule(); const rule2 = getMockRule({ id: '2' }); const mockRules = [rule1, rule2]; + ruleTypeRegistry.get.mockReturnValue({ ...mockRuleType, ruleTaskTimeout: '1d' }); const mockAttributes1 = getMockAdHocRunAttributes({ overwrites: { @@ -248,6 +272,22 @@ describe('BackfillClient', () => { references: [{ id: rule2.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], }, ]); + 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)); }); @@ -315,6 +355,20 @@ describe('BackfillClient', () => { references: [{ id: rule1.id, name: 'rule', type: RULE_SAVED_OBJECT_TYPE }], }, ]); + 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)); }); @@ -363,6 +417,14 @@ describe('BackfillClient', () => { 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), { @@ -445,6 +507,33 @@ describe('BackfillClient', () => { unsecuredSavedObjectsClient, }); + 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: { @@ -514,6 +603,7 @@ describe('BackfillClient', () => { }); expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + expect(taskManagerStart.bulkSchedule).not.toHaveBeenCalled(); expect(result).toEqual([ { error: { @@ -553,5 +643,100 @@ describe('BackfillClient', () => { }, ]); }); + + 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 index 7419e80e929c..54f222b37b4c 100644 --- a/x-pack/plugins/alerting/server/backfill_client/backfill_client.ts +++ b/x-pack/plugins/alerting/server/backfill_client/backfill_client.ts @@ -12,13 +12,22 @@ import { SavedObjectsClientContract, SavedObjectsErrorHelpers, } from '@kbn/core/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, @@ -26,11 +35,17 @@ import { import { RuleDomain } from '../application/rule/types'; import { AdHocRunSO } from '../data/ad_hoc_run/types'; 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 { @@ -43,9 +58,20 @@ interface BulkQueueOpts { 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({ @@ -56,12 +82,36 @@ export class BackfillClient { unsecuredSavedObjectsClient, }: BulkQueueOpts): Promise { const adHocSOsToCreate: Array> = []; - const resultOrErrorMap: Map = new Map(); + + /** + * 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) { - resultOrErrorMap.set(ndx, adHocSOsToCreate.length); + // keep track of index of this request in the adHocSOsToCreate array + soToCreateIndexOrErrorMap.set(ndx, adHocSOsToCreate.length); const reference: SavedObjectReference = { id: rule.id, name: `rule`, @@ -73,7 +123,9 @@ export class BackfillClient { references: [reference], }); } else if (error) { - resultOrErrorMap.set(ndx, 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 @@ -82,26 +134,78 @@ export class BackfillClient { } }); + // Every request encountered an error, so short-circuit the logic here if (!adHocSOsToCreate.length) { - return params.map((_, ndx: number) => resultOrErrorMap.get(ndx) as ScheduleBackfillError); + return params.map( + (_, ndx: number) => soToCreateIndexOrErrorMap.get(ndx) as ScheduleBackfillError + ); } + // Bulk create the saved object const bulkCreateResponse = await unsecuredSavedObjectsClient.bulkCreate( adHocSOsToCreate ); - // TODO bulk schedule the underlying tasks const transformedResponse: ScheduleBackfillResults = bulkCreateResponse.saved_objects.map( transformAdHocRunToBackfillResult ); - return Array.from(resultOrErrorMap.keys()).map((ndx: number) => { - const indexOrError = resultOrErrorMap.get(ndx); + + /** + * 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)) { - return transformedResponse[indexOrError as number]; + // 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; } } 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 index 32623115507f..be03f45749c5 100644 --- 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 @@ -59,4 +59,17 @@ export interface AdHocRunSO extends Record { schedule: AdHocRunSchedule[]; } -export type AdHocRun = Omit & { id: string; rule: AdHocRunRule }; +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/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 790e3f71e071..6e741c862707 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/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 00a9bf7221ef..6aa8eb974444 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 62d2a2f14162..36b00b1d4b6e 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 60636e194e19..4b3a8b9d5201 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 251df68e5267..8231faa43c74 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 684aea523e3b..8008323951c2 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 001b5df614dd..405d194da8a4 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 122168040c9b..907c85ab27da 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -115,6 +115,7 @@ 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', @@ -283,8 +284,15 @@ 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({ @@ -331,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); } @@ -588,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, @@ -603,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/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 32cbd04db76d..a8a3efce24b5 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -86,6 +86,24 @@ export type RuleAttributesNotPartiallyUpdatable = | 'meta' | 'alertDelay'; +export const AdHocRunAttributesToEncrypt = ['apiKeyToUse']; +export const AdHocRunAttributesIncludedInAAD = [ + 'enabled', + 'start', + 'duration', + 'createdAt', + 'rule', + 'spaceId', +]; +export type AdHocRunAttributesNotPartiallyUpdatable = + | 'enabled' + | 'start' + | 'duration' + | 'createdAt' + | 'rule' + | 'spaceId' + | 'apiKeyToUse'; + export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, @@ -204,14 +222,7 @@ export function setupSavedObjects( // Encrypted attributes encryptedSavedObjects.registerType({ type: AD_HOC_RUN_SAVED_OBJECT_TYPE, - attributesToEncrypt: new Set(['apiKeyToUse']), - attributesToIncludeInAAD: new Set([ - 'enabled', - 'start', - 'duration', - 'createdAt', - 'spaceId', - 'rule', - ]), + attributesToEncrypt: new Set(AdHocRunAttributesToEncrypt), + attributesToIncludeInAAD: new Set(AdHocRunAttributesIncludedInAAD), }); } 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 000000000000..f532fdf04eed --- /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 000000000000..bccf28a7cb7b --- /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 000000000000..62e64e02cbec --- /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 000000000000..1ef4279a328a --- /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 000000000000..ae8aa0aca54e --- /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 000000000000..41e0f9e2bf6d --- /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, enabled: false }; +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 000000000000..dad89a829c53 --- /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 000000000000..e30865028867 --- /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 000000000000..d419ffeb72a3 --- /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 3ebb122b3a19..08c1e0c7292e 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 1602e9421ece..3794937343f2 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 9c3271071446..e5779dd6eaca 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 b105c412d07b..da29b8771468 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 c8b5cbb99974..55aa3247aaa7 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 1f3d752f6dff..c5993fe8bc56 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 f28c849b2bc3..fe8bfe86a2f4 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 08d3cbb81244..0e95ad588eda 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 43a870d57c08..a29d9f3c0ad9 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 e8860ec5cf56..1b91cce30edb 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 dd7b14fc7f4a..e6701d26277e 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 858ca01c7597..d09bda0bc0cb 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 561217eeae80..98908f516fc9 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 b8be2221ec1e..f8db21105539 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 6d0ec3635ab4..aa91f7fc5c2d 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 c83a5111772f..2a7d8279e2fa 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 b7e34fb7fd18..8f65682c68fb 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 7cfa6bc17600..a2eaefcf55f8 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 @@ -157,6 +157,7 @@ describe('BurnRateRuleExecutor', () => { executor({ params: someRuleParamsWithWindows({ sloId: 'non-existent' }), startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -178,6 +179,7 @@ describe('BurnRateRuleExecutor', () => { const result = await executor({ params: someRuleParamsWithWindows({ sloId: slo.id }), startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -227,6 +229,7 @@ describe('BurnRateRuleExecutor', () => { await executor({ params: ruleParams, startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -273,6 +276,7 @@ describe('BurnRateRuleExecutor', () => { await executor({ params: ruleParams, startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -329,6 +333,7 @@ describe('BurnRateRuleExecutor', () => { await executor({ params: ruleParams, startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, @@ -440,6 +445,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 aa82fbdeb0b1..528354eed3d0 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 465c49f9aa9b..f64542fef471 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 8f6628298b49..e4086d3f30b1 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 1506ad1dd110..5f3c0cc70e79 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 4ef589edadac..588da8f9db7f 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_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 e3a5e24bd14c..767c01f02b18 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 9281c317ab2e..7a3fd49dcd2d 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 b36d22e505d2..5ffbf59f80e6 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 822d0314375d..add98067223a 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 9a256f91fdaf..4d2636ea6701 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 @@ -861,6 +861,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 def4f2eadd8d..546ab5239561 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 75726039709f..412e2ae77bb5 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/plugins/alerts/server/rule_types.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/rule_types.ts index c463145570ad..109f01f6b6d5 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/rule_types.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/rule_types.ts @@ -705,6 +705,7 @@ function getPatternFiringAutoRecoverFalseRuleType() { defaultActionGroupId: 'default', minimumLicenseRequired: 'basic', isExportable: true, + ruleTaskTimeout: '10s', autoRecoverAlerts: false, async executor(alertExecutorOptions) { const { services, state, params } = alertExecutorOptions; @@ -745,7 +746,14 @@ function getPatternFiringAutoRecoverFalseRuleType() { 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); + } } } 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 index dbac7b470114..5a050f55ea7b 100644 --- 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 @@ -11,5 +11,6 @@ import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; export default function backfillTests({ loadTestFile, getService }: FtrProviderContext) { describe('backfill rule runs', () => { loadTestFile(require.resolve('./schedule')); + 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 index 272faad6056a..a06cc14db9a8 100644 --- 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 @@ -11,7 +11,13 @@ 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 { UserAtSpaceScenarios } from '../../../../scenarios'; -import { checkAAD, getTestRuleData, getUrlPrefix, ObjectRemover } from '../../../../../common/lib'; +import { + checkAAD, + getTestRuleData, + getUrlPrefix, + ObjectRemover, + TaskManagerDoc, +} from '../../../../../common/lib'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -33,6 +39,14 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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', @@ -350,6 +364,26 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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, @@ -602,6 +636,35 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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, @@ -1039,6 +1102,35 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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, 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 000000000000..54b78d952876 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/task_runner.ts @@ -0,0 +1,769 @@ +/* + * 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 + // schedule backfill for both rules as current user + 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/spaces_only/tests/alerting/create_test_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create_test_data.ts index d79fdd086cab..8e6663ff1c29 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 bd79f1dc4a56..2784dadc9948 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 @@ -428,6 +441,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 d13d321280aa..95132afc0122 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 c826ef8910ca..79cb0a4b4edf 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 @@ -75,6 +75,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 2188bf589d8c..3ab0a2e911b5 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 @@ -129,6 +129,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 3161fe1a61a6..84659d80f769 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 2297e65d4824..5414c9c2512c 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 e104a7d25520..4de0ef24b226 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', From 3b4a6031fba76ed17533751a2574876455b26d64 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Mon, 15 Apr 2024 12:34:36 -0400 Subject: [PATCH 06/11] [Response Ops][Alerting] Get/Find/Delete Backfill APIs (merging into feature branch) (#179975) Resolves https://github.com/elastic/kibana/issues/174355 ## Summary Adds `get`, `find`, `delete` APIs for backfills. ### GET `GET kbn:/internal/alerting/rules/backfill/{backfillId}` Returns backfill information by ID ### FIND `POST kbn:/internal/alerting/rules/backfill/_find` **Query parameters** (at least one must be specified; using multiple implies `AND`ing the conditions) `ruleIds` - Returns any scheduled backfills for the given rule IDs `start` - Returns any scheduled backfills where the start of the backfill time range `>= start` `end` - Returns any scheduled backfills where the end of the backfill time range `<= end` ### DELETE `DELETE kbn:/internal/alerting/rules/backfill/{backfillId}` Deletes the specific backfill along with the associated task. Note that the task is created in [this PR](https://github.com/elastic/kibana/pull/177640) so there will need to be some adjustments to the functional test after than PR is merged to the feature branch. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- docs/user/security/audit-logging.asciidoc | 16 + .../src/constants.ts | 2 +- .../current_fields.json | 7 +- .../current_mappings.json | 16 + .../check_registered_types.test.ts | 2 +- x-pack/plugins/alerting/README.md | 5 +- .../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 + .../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 + .../schedule/schedule_backfill.test.ts | 23 + .../methods/schedule/schedule_backfill.ts | 14 +- ...sform_backfill_param_to_ad_hoc_run.test.ts | 2 + .../transform_backfill_param_to_ad_hoc_run.ts | 5 +- .../authorization/alerting_authorization.ts | 5 +- .../backfill_client/backfill_client.test.ts | 116 +++ .../server/backfill_client/backfill_client.ts | 24 +- .../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 + .../transforms/transform_response/v1.ts | 35 +- .../routes/backfill/transforms/index.ts | 9 + .../latest.ts | 8 + .../v1.test.ts | 72 ++ .../v1.ts | 43 + .../plugins/alerting/server/routes/index.ts | 10 +- .../alerting/server/rules_client.mock.ts | 3 + .../rules_client/common/audit_events.test.ts | 106 ++- .../rules_client/common/audit_events.ts | 72 +- .../server/rules_client/rules_client.ts | 10 + .../ad_hoc_run_params_model_versions.ts | 51 ++ .../alerting/server/saved_objects/index.ts | 18 + .../schemas/raw_ad_hoc_run_params/index.ts | 8 + .../schemas/raw_ad_hoc_run_params/v1.ts | 55 ++ .../alerting.test.ts | 32 +- .../feature_privilege_builder/alerting.ts | 5 +- .../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 | 3 + .../tests/alerting/backfill/schedule.ts | 57 +- 73 files changed, 4583 insertions(+), 69 deletions(-) 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/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/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/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/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 diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index f93df0b0298b..0ddc830e4ed4 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. @@ -324,6 +332,14 @@ Refer to the corresponding {es} logs for potential write errors. | `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 dd21fd73ec04..9ca9721f2f2f 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,7 +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|6718cb19f5b39b55a874cee31f859def': '10.0.0', + 'ad_hoc_run_params|364bd28477bb78a536185b39bfb08690': '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-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 319dd04871ec..1358c2670f0b 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -5,7 +5,12 @@ ], "action_task_params": [], "ad_hoc_run_params": [ - "createdAt" + "createdAt", + "end", + "rule", + "rule.alertTypeId", + "rule.consumer", + "start" ], "alert": [ "actions", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 7fc8744b8f08..fef9b8c46035 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -24,6 +24,22 @@ "properties": { "createdAt": { "type": "date" + }, + "end": { + "type": "date" + }, + "rule": { + "properties": { + "alertTypeId": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + } + } + }, + "start": { + "type": "date" } } }, 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 94795c9a99ac..f9892a1c5874 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,7 +57,7 @@ describe('checking migration metadata changes on all registered SO types', () => Object { "action": "cc93fe2c0c76e57c2568c63170e05daea897c136", "action_task_params": "96e27e7f4e8273ffcd87060221e2b75e81912dd5", - "ad_hoc_run_params": "8ee6ecb4a1905dce323df07d0d228f1dd3d83195", + "ad_hoc_run_params": "435f44a2a3b89cb3cb52305dde375d43312070d5", "alert": "3a67d3f1db80af36bd57aaea47ecfef87e43c58f", "api_key_pending_invalidation": "1399e87ca37b3d3a65d269c924eda70726cfe886", "apm-custom-dashboards": "b67128f78160c288bd7efe25b2da6e2afd5e82fc", diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index 52e764b06efa..b14f2e30202b 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -692,7 +692,8 @@ When a user is granted the `read` role in the Alerting Framework, they will be a - `getAlertSummary` - `getExecutionLog` - `find` -- `scheduleBackfill` +- `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: @@ -706,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/routes/backfill/apis/delete/index.ts b/x-pack/plugins/alerting/common/routes/backfill/apis/delete/index.ts new file mode 100644 index 000000000000..8340d9577b1d --- /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 000000000000..25300c97a6d2 --- /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 000000000000..46a65bfb01c3 --- /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 000000000000..25300c97a6d2 --- /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 000000000000..3dfb1c8e78c0 --- /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 000000000000..a4e94492f669 --- /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 000000000000..25300c97a6d2 --- /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 000000000000..b285125af859 --- /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 000000000000..25300c97a6d2 --- /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 000000000000..90f3ccf8592c --- /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 000000000000..27749cbcc291 --- /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 000000000000..25300c97a6d2 --- /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 000000000000..f377ec880f58 --- /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 000000000000..25300c97a6d2 --- /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 000000000000..199bac2fe843 --- /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/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 000000000000..89ab626bed46 --- /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 000000000000..fe43e3555e8d --- /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 000000000000..f560bebe0c42 --- /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 000000000000..e7d4ab364491 --- /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 000000000000..522128d0385f --- /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 000000000000..0a991dd1cc62 --- /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 000000000000..5aa8aabdc02f --- /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 000000000000..b2e0e3ea71e9 --- /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 000000000000..cd46247166ee --- /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 000000000000..8d88732bcad0 --- /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 000000000000..952809acaa72 --- /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 000000000000..75116ac0b3d2 --- /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 000000000000..ecb3dbecae4f --- /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/schedule_backfill.test.ts b/x-pack/plugins/alerting/server/application/backfill/methods/schedule/schedule_backfill.test.ts index 67e4c918f55d..c4944ba66daf 100644 --- 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 @@ -369,7 +369,30 @@ describe('scheduleBackfill()', () => { 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, 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 index f84b1bc4b4ce..9ff777f0108c 100644 --- 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 @@ -18,7 +18,7 @@ import { } from '../../../../rules_client/common/constants'; import { convertRuleIdsToKueryNode } from '../../../../lib'; import { RuleBulkOperationAggregation, RulesClientContext } from '../../../../rules_client'; -import { ReadOperations, AlertingAuthorizationEntity } from '../../../../authorization'; +import { AlertingAuthorizationEntity, WriteOperations } from '../../../../authorization'; import { ruleAuditEvent, RuleAuditAction } from '../../../../rules_client/common/audit_events'; import type { ScheduleBackfillParam, @@ -99,7 +99,7 @@ export async function scheduleBackfill( await context.authorization.ensureAuthorized({ ruleTypeId: ruleType, consumer, - operation: ReadOperations.ScheduleBackfill, + operation: WriteOperations.ScheduleBackfill, entity: AlertingAuthorizationEntity.Rule, }); } catch (error) { @@ -127,11 +127,21 @@ export async function scheduleBackfill( 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!); 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 index 2798bfe027a1..0dd1995e05b9 100644 --- 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 @@ -69,6 +69,8 @@ describe('transformBackfillParamToAdHocRun', () => { 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'], 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 index 2a8b1ca991f3..4dc01a6c8939 100644 --- 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 @@ -17,13 +17,14 @@ export const transformBackfillParamToAdHocRun = ( 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, - ...(param.end ? { end: param.end } : {}), + end: param.end ? param.end : schedule && schedule.length > 0 ? schedule[0].runAt : undefined, rule: { name: rule.name, tags: rule.tags, @@ -43,6 +44,6 @@ export const transformBackfillParamToAdHocRun = ( spaceId, start: param.start, status: adHocRunStatus.PENDING, - schedule: calculateSchedule(param.start, rule.schedule.interval, param.end), + schedule, }; }; diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index b42ee01e3881..2102ff245b9f 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -35,7 +35,8 @@ export enum ReadOperations { Find = 'find', GetAuthorizedAlertsIndices = 'getAuthorizedAlertsIndices', GetRuleExecutionKPI = 'getRuleExecutionKPI', - ScheduleBackfill = 'scheduleBackfill', + GetBackfill = 'getBackfill', + FindBackfill = 'findBackfill', } export enum WriteOperations { @@ -56,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.test.ts b/x-pack/plugins/alerting/server/backfill_client/backfill_client.test.ts index aa9d0d17240c..096d7ddb2e44 100644 --- a/x-pack/plugins/alerting/server/backfill_client/backfill_client.test.ts +++ b/x-pack/plugins/alerting/server/backfill_client/backfill_client.test.ts @@ -17,6 +17,7 @@ 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'; @@ -27,6 +28,7 @@ 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 { @@ -216,6 +218,7 @@ describe('BackfillClient', () => { 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', @@ -253,6 +256,7 @@ describe('BackfillClient', () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); const result = await backfillClient.bulkQueue({ + auditLogger, params: mockData, rules: mockRules, ruleTypeRegistry, @@ -272,6 +276,27 @@ describe('BackfillClient', () => { 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', @@ -299,6 +324,7 @@ describe('BackfillClient', () => { 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', @@ -336,6 +362,7 @@ describe('BackfillClient', () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); const result = await backfillClient.bulkQueue({ + auditLogger, params: mockData, rules: mockRules, ruleTypeRegistry, @@ -355,6 +382,27 @@ describe('BackfillClient', () => { 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', @@ -383,6 +431,7 @@ describe('BackfillClient', () => { 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', @@ -400,6 +449,7 @@ describe('BackfillClient', () => { unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce(bulkCreateResult); const result = await backfillClient.bulkQueue({ + auditLogger, params: mockData, rules: mockRules, ruleTypeRegistry, @@ -414,6 +464,17 @@ describe('BackfillClient', () => { 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\"}` ); @@ -500,12 +561,65 @@ describe('BackfillClient', () => { 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([ { @@ -595,6 +709,7 @@ describe('BackfillClient', () => { ]; const result = await backfillClient.bulkQueue({ + auditLogger, params: mockData, rules: [], ruleTypeRegistry, @@ -603,6 +718,7 @@ describe('BackfillClient', () => { }); expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + expect(auditLogger.log).not.toHaveBeenCalled(); expect(taskManagerStart.bulkSchedule).not.toHaveBeenCalled(); expect(result).toEqual([ { diff --git a/x-pack/plugins/alerting/server/backfill_client/backfill_client.ts b/x-pack/plugins/alerting/server/backfill_client/backfill_client.ts index 54f222b37b4c..7b4c7aeea225 100644 --- a/x-pack/plugins/alerting/server/backfill_client/backfill_client.ts +++ b/x-pack/plugins/alerting/server/backfill_client/backfill_client.ts @@ -7,11 +7,13 @@ import { Logger, + SavedObject, SavedObjectReference, SavedObjectsBulkCreateObject, SavedObjectsClientContract, SavedObjectsErrorHelpers, } from '@kbn/core/server'; +import { AuditLogger } from '@kbn/security-plugin/server'; import { RunContext, TaskInstance, @@ -34,6 +36,7 @@ import { } 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'; @@ -49,6 +52,7 @@ interface ConstructorOpts { } interface BulkQueueOpts { + auditLogger?: AuditLogger; params: ScheduleBackfillParams; rules: RuleDomain[]; ruleTypeRegistry: RuleTypeRegistry; @@ -75,6 +79,7 @@ export class BackfillClient { } public async bulkQueue({ + auditLogger, params, rules, ruleTypeRegistry, @@ -147,7 +152,24 @@ export class BackfillClient { ); const transformedResponse: ScheduleBackfillResults = bulkCreateResponse.saved_objects.map( - transformAdHocRunToBackfillResult + (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); + } ); /** 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 000000000000..55a8ea2ae22e --- /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 000000000000..57e0a2ed2af8 --- /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 000000000000..fb461fa416f9 --- /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 000000000000..0e8dcdb6fc28 --- /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 000000000000..2eab64276e02 --- /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 000000000000..25300c97a6d2 --- /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 000000000000..3425f5dea83f --- /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 000000000000..25300c97a6d2 --- /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 000000000000..ce959b92d963 --- /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 000000000000..f24018b1c8de --- /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 000000000000..6d84aee4a5f8 --- /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/transforms/transform_response/v1.ts b/x-pack/plugins/alerting/server/routes/backfill/apis/schedule/transforms/transform_response/v1.ts index ca83f4b78746..697169277e75 100644 --- 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 @@ -12,6 +12,7 @@ import type { ScheduleBackfillResults, } from '../../../../../../application/backfill/methods/schedule/types'; import { ScheduleBackfillResponseBodyV1 } from '../../../../../../../common/routes/backfill/apis/schedule'; +import { transformBackfillToBackfillResponseV1 } from '../../../../transforms'; export const transformResponse = ( results: ScheduleBackfillResults @@ -21,38 +22,6 @@ export const transformResponse = ( return result as ScheduleBackfillError; } - const backfillResult = result as Backfill; - const { createdAt, rule, spaceId, schedule, ...rest } = backfillResult; - - 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, - })), - }; + 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 000000000000..64e4938b3697 --- /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 000000000000..cc4284826acb --- /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 000000000000..88582bea15b9 --- /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 000000000000..c1c0a3aa53cd --- /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 cc67c5430b7c..e41055d733d1 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -66,8 +66,11 @@ 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 scheduling API +// 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; @@ -143,6 +146,9 @@ export function defineRoutes(opts: RouteOptions) { getQueryDelaySettingsRoute(router, licenseState); updateQueryDelaySettingsRoute(router, licenseState); - // backfill scheduling API + // 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 52031b106307..eedd46eaa71e 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -42,6 +42,9 @@ const createRulesClientMock = () => { 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 6d42be630ffd..1ec083150550 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 9b19e0fc15e5..1ab77379cbea 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', @@ -37,9 +38,16 @@ export enum RuleAuditAction { 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'], @@ -91,7 +99,14 @@ const eventVerbs: Record = { ], }; -const eventTypes: Record> = { +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 ruleEventTypes: Record> = { rule_create: 'creation', rule_get: 'access', rule_resolve: 'access', @@ -119,6 +134,13 @@ const eventTypes: Record> = { 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 { action: RuleAuditAction; outcome?: EcsEvent['outcome']; @@ -126,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, @@ -133,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/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 25c93fa6cdb6..3f8448762604 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -71,6 +71,10 @@ import { } 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, @@ -187,6 +191,12 @@ export class RulesClient { 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/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 000000000000..10d8dc759e9b --- /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 a8a3efce24b5..1771d748195a 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -30,6 +30,7 @@ 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'; @@ -194,6 +195,22 @@ export function setupSavedObjects( 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' @@ -203,6 +220,7 @@ export function setupSavedObjects( management: { importableAndExportable: false, }, + modelVersions: adHocRunParamsModelVersions, }); // Encrypted attributes 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 000000000000..977a13f3a7e4 --- /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 000000000000..8676c2c60691 --- /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/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 2a7eef1bf098..db4aae642a56 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,7 +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/scheduleBackfill", + "alerting:alert-type/my-feature/rule/getBackfill", + "alerting:alert-type/my-feature/rule/findBackfill", ] `); }); @@ -178,7 +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/scheduleBackfill", + "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", @@ -226,7 +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/scheduleBackfill", + "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", @@ -244,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", ] `); }); @@ -332,7 +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/scheduleBackfill", + "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", @@ -350,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", @@ -398,7 +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/scheduleBackfill", + "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", @@ -416,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", @@ -423,7 +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/scheduleBackfill", + "alerting:readonly-alert-type/my-feature/rule/getBackfill", + "alerting:readonly-alert-type/my-feature/rule/findBackfill", ] `); }); @@ -516,7 +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/scheduleBackfill", + "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", @@ -534,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", @@ -541,7 +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/scheduleBackfill", + "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 2c5badc98fc6..c0b7fa2ea8ab 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,7 +25,8 @@ const readOperations: Record = { 'getActionErrorLog', 'find', 'getRuleExecutionKPI', - 'scheduleBackfill', + 'getBackfill', + 'findBackfill', ], alert: ['get', 'find', 'getAuthorizedAlertsIndices', 'getAlertSummary'], }; @@ -49,6 +50,8 @@ const writeOperations: Record = { 'bulkDisable', 'unsnooze', 'runSoon', + 'scheduleBackfill', + 'deleteBackfill', ], alert: ['update'], }; 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 000000000000..41d73f7e3f2e --- /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 000000000000..5f49b35a2996 --- /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 000000000000..6a9e0a7194b5 --- /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 index 5a050f55ea7b..28215738368d 100644 --- 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 @@ -11,6 +11,9 @@ import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; export default function backfillTests({ loadTestFile, getService }: FtrProviderContext) { describe('backfill rule runs', () => { 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 index a06cc14db9a8..0f32a21d64d6 100644 --- 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 @@ -10,6 +10,7 @@ import { ALERTING_CASES_SAVED_OBJECT_INDEX, SavedObject } from '@kbn/core-saved- 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, @@ -27,9 +28,18 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext const supertestWithoutAuth = getService('supertestWithoutAuth'); describe('schedule backfill', () => { + let backfillIds: Array<{ id: string; spaceId: string }> = []; const objectRemover = new ObjectRemover(supertest); - after(() => objectRemover.removeAll()); + 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({ @@ -162,6 +172,13 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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 @@ -175,6 +192,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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'); @@ -247,10 +265,11 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext ]); 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.be(undefined); + 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'); @@ -348,7 +367,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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.be(undefined); + 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); @@ -446,6 +465,13 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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 @@ -459,6 +485,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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'); @@ -491,10 +518,11 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext ]); 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.be(undefined); + 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'); @@ -508,6 +536,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext ]); 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'); @@ -586,7 +615,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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.be(undefined); + 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); @@ -924,6 +953,13 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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 @@ -939,6 +975,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // 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'); @@ -967,10 +1004,11 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // 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.be(undefined); + 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'); @@ -1009,10 +1047,11 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext // 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.be(undefined); + 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'); @@ -1067,7 +1106,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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.be(undefined); + 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); @@ -1085,7 +1124,7 @@ export default function scheduleBackfillTests({ getService }: FtrProviderContext 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.be(undefined); + 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); From 0572829718eec4afef2d75b2e7e1aff5b529cd78 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 17 Apr 2024 19:49:49 +0000 Subject: [PATCH 07/11] [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' --- .../observability_solution/ux/public/application/ux_app.tsx | 5 +---- .../components/app/rum_dashboard/utils/test_helper.tsx | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/observability_solution/ux/public/application/ux_app.tsx b/x-pack/plugins/observability_solution/ux/public/application/ux_app.tsx index f9cc26929c8f..bf7c6a2c7e7b 100644 --- a/x-pack/plugins/observability_solution/ux/public/application/ux_app.tsx +++ b/x-pack/plugins/observability_solution/ux/public/application/ux_app.tsx @@ -18,10 +18,7 @@ import { AppMountParameters, CoreStart, APP_WRAPPER_CLASS } from '@kbn/core/publ import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme'; -import { - KibanaContextProvider, - useDarkMode, -} from '@kbn/kibana-react-plugin/public'; +import { KibanaContextProvider, useDarkMode } from '@kbn/kibana-react-plugin/public'; import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; diff --git a/x-pack/plugins/observability_solution/ux/public/components/app/rum_dashboard/utils/test_helper.tsx b/x-pack/plugins/observability_solution/ux/public/components/app/rum_dashboard/utils/test_helper.tsx index 4111250d26a3..f297a029a341 100644 --- a/x-pack/plugins/observability_solution/ux/public/components/app/rum_dashboard/utils/test_helper.tsx +++ b/x-pack/plugins/observability_solution/ux/public/components/app/rum_dashboard/utils/test_helper.tsx @@ -18,9 +18,7 @@ import { UrlParamsProvider } from '../../../../context/url_params_context/url_pa const core = coreMock.createStart(); jest.spyOn(core.uiSettings, 'get').mockImplementation((_key: string) => true); -jest - .spyOn(core.uiSettings, 'get$') - .mockImplementation((_key: string) => of(true)); +jest.spyOn(core.uiSettings, 'get$').mockImplementation((_key: string) => of(true)); export const render = (component: React.ReactNode, options: { customHistory: MemoryHistory }) => { const history = options?.customHistory ?? createMemoryHistory(); From 239eb653b1d6bbadd089bb46b7d89e9f4cbfcd88 Mon Sep 17 00:00:00 2001 From: Ying Date: Wed, 17 Apr 2024 16:43:53 -0400 Subject: [PATCH 08/11] Fixing type after merge --- .../slo/server/lib/rules/slo_burn_rate/executor.test.ts | 1 + 1 file changed, 1 insertion(+) 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 05519aaedda4..c3b830f445b3 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 @@ -508,6 +508,7 @@ describe('BurnRateRuleExecutor', () => { await executor({ params: ruleParams, startedAt: new Date(), + startedAtOverridden: false, services: servicesMock, executionId: 'irrelevant', logger: loggerMock, From cc15082f42ac8ce666781958682c52fa8012b062 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 18 Apr 2024 14:48:45 -0400 Subject: [PATCH 09/11] [Response Ops][Alerting] Updating invalidate API key task to exclude API keys being used by backfills (merging into feature branch) (#180749) Resolves https://github.com/elastic/kibana/issues/174359 ## Summary This PR updates the invalidate API key task to query for API keys used by ad hoc runs. The ad hoc run saved object stores the unencrypted API key ID (a decision that was approved by the kibana-security team) that can be used in the query. The invalidate API key task queries the `.kibana` index for `api_key_pending_invalidation` tasks and gets the API key ID for key pending invalidation. This PR adds a query with aggregation against the `.kibana` index to look for any `ad_hoc_run_param` saved objects that reference the API key ID and excludes them from invalidation. The invalidation task runs at a scheduled interval so the API key will still be invalidated, just after the ad hoc run task that uses it is done. ## To Verify 1. Change the default config for the invalidation task so it runs faster and then start Kibana ``` diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index a49c393da1e..eb188b747e4 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -65,8 +65,8 @@ export const configSchema = schema.object({ interval: schema.string({ validate: validateDurationSchema, defaultValue: '60m' }), }), invalidateApiKeysTask: schema.object({ - interval: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), - removalDelay: schema.string({ validate: validateDurationSchema, defaultValue: '1h' }), + interval: schema.string({ validate: validateDurationSchema, defaultValue: '1m' }), + removalDelay: schema.string({ validate: validateDurationSchema, defaultValue: '1m' }), }), ``` 2. Create 2 detection rules. 3. Schedule a backfill for one of the rules. Use a long start/end time range so the backfill takes a while to run 4. Perform an action on both rules that would invalidate the API key for these rules. You can update the rule, or delete them, or update their API keys. 5. Verify that the backfill task continues running with no errors. Verify that the API key for the rule with no backfill task gets deleted fairly quickly while the API key for the rule with the backfill task sticks around until the backfill is complete. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/constants.ts | 2 +- .../current_fields.json | 1 + .../current_mappings.json | 3 + .../check_registered_types.test.ts | 2 +- x-pack/plugins/alerting/server/index.ts | 2 +- ...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 ++-- .../server/rules_client/tests/enable.test.ts | 10 +- .../server/rules_client_factory.test.ts | 10 +- .../alerting/server/rules_client_factory.ts | 8 +- .../alerting/server/saved_objects/index.ts | 14 +- .../alerting_api_integration/common/config.ts | 1 + .../common/plugins/alerts/server/routes.ts | 9 +- .../group1/tests/alerting/backfill/api_key.ts | 243 ++++ .../group1/tests/alerting/backfill/index.ts | 3 +- .../tests/alerting/backfill/task_runner.ts | 1 - 18 files changed, 1588 insertions(+), 99 deletions(-) create mode 100644 x-pack/plugins/alerting/server/invalidate_pending_api_keys/task.test.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/api_key.ts 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 42cbec93d290..dee7da41e9a3 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,7 +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|364bd28477bb78a536185b39bfb08690': '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-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 137d0a7a8823..106a894fd490 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -5,6 +5,7 @@ ], "action_task_params": [], "ad_hoc_run_params": [ + "apiKeyId", "createdAt", "end", "rule", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 556b6a52f722..25fa2c833555 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -22,6 +22,9 @@ "ad_hoc_run_params": { "dynamic": false, "properties": { + "apiKeyId": { + "type": "keyword" + }, "createdAt": { "type": "date" }, 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 6ea9cdd9c56a..699040685356 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,7 +57,7 @@ describe('checking migration metadata changes on all registered SO types', () => Object { "action": "cc93fe2c0c76e57c2568c63170e05daea897c136", "action_task_params": "96e27e7f4e8273ffcd87060221e2b75e81912dd5", - "ad_hoc_run_params": "435f44a2a3b89cb3cb52305dde375d43312070d5", + "ad_hoc_run_params": "d4e3c5c794151d0a4f5c71e886b2aa638da73ad2", "alert": "3a67d3f1db80af36bd57aaea47ecfef87e43c58f", "api_key_pending_invalidation": "1399e87ca37b3d3a65d269c924eda70726cfe886", "apm-custom-dashboards": "b67128f78160c288bd7efe25b2da6e2afd5e82fc", diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index c6746d33df67..84d99c15d805 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/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 34545292bf5f..270661363a5b 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 290740a4ddd8..6711dd3fd6d2 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 000000000000..a7177b248974 --- /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 3f8dac166b58..48eea48246c7 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/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index 417250f27d1a..695f1185ceff 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,7 @@ 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', () => { @@ -239,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', @@ -299,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_factory.test.ts b/x-pack/plugins/alerting/server/rules_client_factory.test.ts index 9a5404950fe5..9be3e17e6371 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.test.ts @@ -27,7 +27,11 @@ 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, RULE_SAVED_OBJECT_TYPE } from './saved_objects'; +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'; @@ -97,7 +101,7 @@ test('creates a rules client with proper constructor arguments when security is excludedExtensions: [SECURITY_EXTENSION_ID], includedHiddenTypes: [ RULE_SAVED_OBJECT_TYPE, - 'api_key_pending_invalidation', + API_KEY_PENDING_INVALIDATION_TYPE, AD_HOC_RUN_SAVED_OBJECT_TYPE, ], }); @@ -153,7 +157,7 @@ test('creates a rules client with proper constructor arguments', async () => { excludedExtensions: [SECURITY_EXTENSION_ID], includedHiddenTypes: [ RULE_SAVED_OBJECT_TYPE, - 'api_key_pending_invalidation', + API_KEY_PENDING_INVALIDATION_TYPE, AD_HOC_RUN_SAVED_OBJECT_TYPE, ], }); diff --git a/x-pack/plugins/alerting/server/rules_client_factory.ts b/x-pack/plugins/alerting/server/rules_client_factory.ts index 51fbd9c13048..50a11dd178c3 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.ts @@ -30,7 +30,11 @@ 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, RULE_SAVED_OBJECT_TYPE } from './saved_objects'; +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'; export interface RulesClientFactoryOpts { logger: Logger; @@ -128,7 +132,7 @@ export class RulesClientFactory { excludedExtensions: [SECURITY_EXTENSION_ID], includedHiddenTypes: [ RULE_SAVED_OBJECT_TYPE, - 'api_key_pending_invalidation', + API_KEY_PENDING_INVALIDATION_TYPE, AD_HOC_RUN_SAVED_OBJECT_TYPE, ], }), diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 1771d748195a..0e745794fc30 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -34,6 +34,7 @@ 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']; @@ -146,7 +147,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', @@ -186,12 +187,9 @@ export function setupSavedObjects( mappings: { dynamic: false, properties: { - // shape is defined in x-pack/plugins/alerting/server/data/ad_hoc_run/types/ad_hoc_run.ts - // TODO to allow invalidate api key task to query for backfill jobs still - // using the API key - // apiKeyId: { - // type: 'keyword' - // }, + apiKeyId: { + type: 'keyword', + }, createdAt: { type: 'date', }, @@ -232,7 +230,7 @@ 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']), }); diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 46a3119ab0d2..abaf73c5afc6 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -200,6 +200,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/alerts/server/routes.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts index fb6845ca7096..30a13ce2f76f 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts @@ -26,7 +26,10 @@ import { import { SECURITY_EXTENSION_ID, SPACES_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import { queryOptionsSchema } from '@kbn/event-log-plugin/server/event_log_client'; import { NotificationsPluginStart } from '@kbn/notifications-plugin/server'; -import { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; +import { + RULE_SAVED_OBJECT_TYPE, + API_KEY_PENDING_INVALIDATION_TYPE, +} from '@kbn/alerting-plugin/server'; import { ActionExecutionSourceType } from '@kbn/actions-plugin/server/types'; import { FixtureStartDeps } from './plugin'; import { retryIfConflicts } from './lib/retry_if_conflicts'; @@ -293,10 +296,10 @@ export function defineRoutes( try { const [{ savedObjects }] = await core.getStartServices(); const savedObjectsWithTasksAndAlerts = await savedObjects.getScopedClient(req, { - includedHiddenTypes: ['api_key_pending_invalidation'], + includedHiddenTypes: [API_KEY_PENDING_INVALIDATION_TYPE], }); const findResult = await savedObjectsWithTasksAndAlerts.find({ - type: 'api_key_pending_invalidation', + type: API_KEY_PENDING_INVALIDATION_TYPE, }); return res.ok({ body: { apiKeysToInvalidate: findResult.saved_objects }, 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 000000000000..2f704cc4b814 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/api_key.ts @@ -0,0 +1,243 @@ +/* + * 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); + + 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 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; + }); + + // 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'); + } + + // 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/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/backfill/index.ts index 28215738368d..ee2e5b9decdb 100644 --- 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 @@ -8,8 +8,9 @@ import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export -export default function backfillTests({ loadTestFile, getService }: FtrProviderContext) { +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')); 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 index 54b78d952876..8aed8bb12e36 100644 --- 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 @@ -166,7 +166,6 @@ export default function createBackfillTaskRunnerTests({ getService }: FtrProvide objectRemover.add(spaceId, ruleId, 'rule', 'alerting'); // Schedule backfill for this rule - // schedule backfill for both rules as current user const response2 = await supertestWithoutAuth .post(`${getUrlPrefix(spaceId)}/internal/alerting/rules/backfill/_schedule`) .set('kbn-xsrf', 'foo') From 34226fbfaf46d36e8c43c733ec49a201e815b4ea Mon Sep 17 00:00:00 2001 From: Ying Date: Mon, 22 Apr 2024 19:32:15 -0400 Subject: [PATCH 10/11] Fixing flaky test --- .../alerting_api_integration/common/config.ts | 1 - .../common/plugins/alerts/server/routes.ts | 20 +++++++++++++++++++ .../group1/tests/alerting/backfill/api_key.ts | 18 +++++++++++++++++ .../group1/tests/alerting/index.ts | 2 +- .../group2/tests/alerting/update.ts | 18 ++++++++++++----- 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index abaf73c5afc6..2b4f8e2cd8af 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -199,7 +199,6 @@ 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"', diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts index 30a13ce2f76f..6530a1768826 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/routes.ts @@ -398,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/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 index 2f704cc4b814..44e2c3f0252a 100644 --- 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 @@ -30,6 +30,10 @@ export default function apiKeyBackfillTests({ getService }: FtrProviderContext) describe('backfill api key invalidation', () => { const objectRemover = new ObjectRemover(supertest); + beforeEach(async () => { + await runInvalidateTask(); + }); + after(() => objectRemover.removeAll()); async function getAdHocRunSO(id: string) { @@ -40,6 +44,14 @@ export default function apiKeyBackfillTests({ getService }: FtrProviderContext) 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, @@ -208,6 +220,9 @@ export default function apiKeyBackfillTests({ getService }: FtrProviderContext) 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(); @@ -232,6 +247,9 @@ export default function apiKeyBackfillTests({ getService }: FtrProviderContext) 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(); 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 18c2d7a1b0ba..ec938e8dc4ab 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')); @@ -34,7 +35,6 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./retain_api_key')); loadTestFile(require.resolve('./bulk_untrack')); loadTestFile(require.resolve('./bulk_untrack_by_query')); - loadTestFile(require.resolve('./backfill')); }); }); } 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 feba08b7d91e..8b9d4929d566 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 From 23cda6db82d06dfe7004b20f08a085e4159d7646 Mon Sep 17 00:00:00 2001 From: Ying Date: Tue, 23 Apr 2024 09:00:24 -0400 Subject: [PATCH 11/11] Reducing number of AAD fields to minimum --- .../alerting/server/saved_objects/index.ts | 18 ++---------------- .../lib/partially_update_ad_hoc_run.test.ts | 2 +- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 0e745794fc30..0dd261c4c39f 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -89,22 +89,8 @@ export type RuleAttributesNotPartiallyUpdatable = | 'alertDelay'; export const AdHocRunAttributesToEncrypt = ['apiKeyToUse']; -export const AdHocRunAttributesIncludedInAAD = [ - 'enabled', - 'start', - 'duration', - 'createdAt', - 'rule', - 'spaceId', -]; -export type AdHocRunAttributesNotPartiallyUpdatable = - | 'enabled' - | 'start' - | 'duration' - | 'createdAt' - | 'rule' - | 'spaceId' - | 'apiKeyToUse'; +export const AdHocRunAttributesIncludedInAAD = ['rule', 'spaceId']; +export type AdHocRunAttributesNotPartiallyUpdatable = 'rule' | 'spaceId' | 'apiKeyToUse'; export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, 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 index 41e0f9e2bf6d..4e6e77269344 100644 --- 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 @@ -146,7 +146,7 @@ const DefaultAttributes = { ], }; -const UnallowedAttributes = { ...DefaultAttributes, enabled: false }; +const UnallowedAttributes = { ...DefaultAttributes, spaceId: 'yo' }; const ExtraneousAttributes = { ...DefaultAttributes, foo: 'bar' }; const MockAdHocRunId = 'abc';