From b735d8c56925b4f01e239f017ed9204eee8beb18 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 12 Apr 2024 12:01:17 +0300 Subject: [PATCH] [Cases] Case action (#168369) ## Summary Depends on: https://github.com/elastic/kibana/pull/166267, https://github.com/elastic/kibana/pull/170326, https://github.com/elastic/kibana/pull/169484, https://github.com/elastic/kibana/pull/173740, https://github.com/elastic/kibana/pull/173763, https://github.com/elastic/kibana/pull/178068, https://github.com/elastic/kibana/pull/178307, https://github.com/elastic/kibana/pull/178600, https://github.com/elastic/kibana/pull/180437 PRs: - https://github.com/elastic/kibana/pull/168370 - https://github.com/elastic/kibana/pull/169229 - https://github.com/elastic/kibana/pull/171754 - https://github.com/elastic/kibana/pull/172709 - https://github.com/elastic/kibana/pull/173012 - https://github.com/elastic/kibana/pull/175107 - https://github.com/elastic/kibana/pull/175452 - https://github.com/elastic/kibana/pull/175505 - https://github.com/elastic/kibana/pull/177033 - https://github.com/elastic/kibana/pull/178277 - https://github.com/elastic/kibana/pull/177139 - https://github.com/elastic/kibana/pull/179796 Fixes: https://github.com/elastic/kibana/issues/153837 ## Testing Run Kibana with `--run-examples` if you want to use the "Always firing" rule. Create a rule with a case action in observability and the stack. The security solution is not supported. You should not be able to assign a case action in a security solution rule. 1. Test the "Reopen closed cases" configuration. 2. Test the "Grouping by" configuration. Only one field is allowed. Not all fields are persisted in alerts. If you select a field not part of the alert the case action will create a case where the grouping value is set to `unknow`. 3. Test the "Time window" feature. You can comment out the validation to test for shorter times. 4. Verify that the case action is experimental. 5. Verify that based on the rule type the case is created in the correct solution. 6. Verify that you cannot create a rule with the case action on the basic license. 7. Verify that the execution of the case action fails if you do not have permission for cases. Pending work on the system actions framework level to not allow users to create rules with system actions where they do not have permission. 8. Stress test the case action by creating multiple rules. ### Checklist Delete any items that are not applicable to this PR. - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ## Release notes Automatically create cases when an alert is triggered. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: adcoelho Co-authored-by: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> --- .../src/constants.ts | 1 + .../current_fields.json | 7 + .../current_mappings.json | 27 +- .../check_registered_types.test.ts | 1 + .../group3/type_registrations.test.ts | 1 + .../group5/dot_kibana_split.test.ts | 1 + .../server/create_system_actions.test.ts | 2 +- .../actions/server/create_system_actions.ts | 2 +- .../connector_types.test.ts.snap | 373 +++ .../mocks/connector_types.ts | 1 + x-pack/plugins/actions/server/plugin.test.ts | 4 +- .../server/sub_action_framework/executor.ts | 3 +- .../sub_action_connector.ts | 3 + .../server/sub_action_framework/types.ts | 2 + x-pack/plugins/actions/tsconfig.json | 1 + .../server/connector_adapters/types.ts | 2 +- .../task_runner/execution_handler.test.ts | 2 + .../server/task_runner/execution_handler.ts | 2 +- .../plugins/cases/common/constants/index.ts | 9 + .../cases/common/constants/owner.test.ts | 26 + .../plugins/cases/common/constants/owners.ts | 14 + x-pack/plugins/cases/kibana.jsonc | 1 + .../system_actions/cases/cases.test.tsx | 86 + .../components/system_actions/cases/cases.tsx | 58 + .../cases/cases_params.test.tsx | 233 ++ .../system_actions/cases/cases_params.tsx | 210 ++ .../system_actions/cases/constants.ts | 13 + .../system_actions/cases/translations.ts | 67 + .../components/system_actions/cases/types.ts | 16 + .../system_actions/cases/utils.test.ts | 50 + .../components/system_actions/cases/utils.ts | 27 + .../system_actions/hooks/alert_fields.ts | 27 + .../system_actions/hooks/alert_index.ts | 25 + .../hooks/use_alert_data_view.test.tsx | 135 + .../hooks/use_alert_data_view.ts | 161 + .../public/components/system_actions/index.ts | 13 + x-pack/plugins/cases/public/plugin.test.ts | 1 + x-pack/plugins/cases/public/plugin.ts | 3 + x-pack/plugins/cases/public/types.ts | 6 +- .../{update.test.ts => bulk_update.test.ts} | 94 +- .../cases/{update.ts => bulk_update.ts} | 2 +- .../cases/server/client/cases/client.ts | 6 +- x-pack/plugins/cases/server/client/mocks.ts | 2 +- .../cases/server/connectors/cases/README.md | 579 ++++ .../connectors/cases/cases_connector.test.ts | 288 ++ .../connectors/cases/cases_connector.ts | 210 ++ .../connectors/cases/cases_connector_error.ts | 24 + .../cases/cases_connector_executor.test.ts | 2737 +++++++++++++++++ .../cases/cases_connector_executor.ts | 1103 +++++++ .../cases/cases_oracle_service.test.ts | 543 ++++ .../connectors/cases/cases_oracle_service.ts | 259 ++ .../connectors/cases/cases_service.test.ts | 165 + .../server/connectors/cases/cases_service.ts | 35 + .../server/connectors/cases/constants.ts | 18 + .../connectors/cases/crypto_service.test.ts | 61 + .../server/connectors/cases/crypto_service.ts | 26 + .../cases/full_jitter_backoff.test.ts | 69 + .../connectors/cases/full_jitter_backoff.ts | 60 + .../server/connectors/cases/index.mock.ts | 176 ++ .../server/connectors/cases/index.test.ts | 288 ++ .../cases/server/connectors/cases/index.ts | 116 + .../connectors/cases/retry_service.test.ts | 132 + .../server/connectors/cases/retry_service.ts | 114 + .../server/connectors/cases/schema.test.ts | 196 ++ .../cases/server/connectors/cases/schema.ts | 93 + .../server/connectors/cases/test_helpers.ts | 116 + .../cases/server/connectors/cases/types.ts | 91 + .../server/connectors/cases/utils.test.ts | 255 ++ .../cases/server/connectors/cases/utils.ts | 97 + .../plugins/cases/server/connectors/index.ts | 51 + x-pack/plugins/cases/server/plugin.test.ts | 1 + x-pack/plugins/cases/server/plugin.ts | 80 +- .../server/routes/api/cases/patch_cases.ts | 2 +- .../server/saved_object_types/cases_rules.ts | 61 + .../cases/server/saved_object_types/index.ts | 51 +- .../telemetry/collect_telemetry_data.ts | 31 +- .../plugins/cases/server/telemetry/index.ts | 7 +- .../queries/case_system_action.test.ts | 45 + .../telemetry/queries/case_system_action.ts | 35 + .../plugins/cases/server/telemetry/schema.ts | 4 + .../plugins/cases/server/telemetry/types.ts | 4 + x-pack/plugins/cases/server/types.ts | 5 +- x-pack/plugins/cases/tsconfig.json | 4 + .../feature_privilege_builder/cases.ts | 2 +- .../schema/xpack_plugins.json | 10 + .../action_type_form.tsx | 1 + .../system_action_type_form.tsx | 1 + .../rule_details/components/rule_actions.tsx | 25 +- .../triggers_actions_ui/public/types.ts | 1 + .../group2/tests/actions/bulk_enqueue.ts | 2 +- .../group2/tests/actions/execute.ts | 2 +- .../group2/tests/actions/get_all_system.ts | 100 +- .../group4/tests/alerting/alerts.ts | 2 +- .../check_registered_connector_types.ts | 1 + .../spaces_only/tests/actions/execute.ts | 4 +- .../tests/actions/get_all_system.ts | 67 +- .../cases_api_integration/common/config.ts | 1 + .../common/lib/api/connectors.ts | 53 +- .../common/lib/authentication/roles.ts | 26 +- .../common/lib/authentication/users.ts | 12 +- .../common/plugins/cases/kibana.jsonc | 1 + .../common/plugins/cases/server/plugin.ts | 2 + .../common/plugins/cases/server/routes.ts | 42 +- .../trial/connectors/cases/cases_connector.ts | 1384 +++++++++ .../security_and_spaces/tests/trial/index.ts | 3 + .../check_registered_task_types.ts | 1 + 106 files changed, 11496 insertions(+), 199 deletions(-) create mode 100644 x-pack/plugins/cases/common/constants/owner.test.ts create mode 100644 x-pack/plugins/cases/public/components/system_actions/cases/cases.test.tsx create mode 100644 x-pack/plugins/cases/public/components/system_actions/cases/cases.tsx create mode 100644 x-pack/plugins/cases/public/components/system_actions/cases/cases_params.test.tsx create mode 100644 x-pack/plugins/cases/public/components/system_actions/cases/cases_params.tsx create mode 100644 x-pack/plugins/cases/public/components/system_actions/cases/constants.ts create mode 100644 x-pack/plugins/cases/public/components/system_actions/cases/translations.ts create mode 100644 x-pack/plugins/cases/public/components/system_actions/cases/types.ts create mode 100644 x-pack/plugins/cases/public/components/system_actions/cases/utils.test.ts create mode 100644 x-pack/plugins/cases/public/components/system_actions/cases/utils.ts create mode 100644 x-pack/plugins/cases/public/components/system_actions/hooks/alert_fields.ts create mode 100644 x-pack/plugins/cases/public/components/system_actions/hooks/alert_index.ts create mode 100644 x-pack/plugins/cases/public/components/system_actions/hooks/use_alert_data_view.test.tsx create mode 100644 x-pack/plugins/cases/public/components/system_actions/hooks/use_alert_data_view.ts create mode 100644 x-pack/plugins/cases/public/components/system_actions/index.ts rename x-pack/plugins/cases/server/client/cases/{update.test.ts => bulk_update.test.ts} (97%) rename x-pack/plugins/cases/server/client/cases/{update.ts => bulk_update.ts} (99%) create mode 100644 x-pack/plugins/cases/server/connectors/cases/README.md create mode 100644 x-pack/plugins/cases/server/connectors/cases/cases_connector.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/cases_connector.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/cases_connector_error.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/cases_connector_executor.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/cases_connector_executor.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/cases_service.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/cases_service.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/constants.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/crypto_service.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/crypto_service.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/full_jitter_backoff.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/full_jitter_backoff.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/index.mock.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/index.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/index.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/retry_service.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/retry_service.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/schema.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/schema.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/test_helpers.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/types.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/utils.test.ts create mode 100644 x-pack/plugins/cases/server/connectors/cases/utils.ts create mode 100644 x-pack/plugins/cases/server/saved_object_types/cases_rules.ts create mode 100644 x-pack/plugins/cases/server/telemetry/queries/case_system_action.test.ts create mode 100644 x-pack/plugins/cases/server/telemetry/queries/case_system_action.ts create mode 100644 x-pack/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.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 0a09032d290e5..ea7d10b730643 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 @@ -146,6 +146,7 @@ export const HASH_TO_VERSION_MAP = { 'cases-comments|93535d41ca0279a4a2e5d08acd3f28e3': '10.0.0', 'cases-configure|c124bd0be4c139d0f0f91fb9eeca8e37': '10.0.0', 'cases-connector-mappings|a98c33813f364f0b068e8c592ac6ef6d': '10.0.0', + 'cases-rules|1cb4b03690489e07aa86f283dcea5ce1': '10.0.0', 'cases-telemetry|3d1b76c39bfb2cc8296b024d73854724': '10.0.0', 'cases-user-actions|07a6651cf37853dd5d64bfb2c796e102': '10.0.0', 'cases|8f7dc53b17c272ea19f831537daa082d': '10.1.0', diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 33aace8260291..c3f9bfc7a655d 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -195,6 +195,13 @@ "cases-connector-mappings": [ "owner" ], + "cases-rules": [ + "counter", + "createdAt", + "rules", + "rules.id", + "updatedAt" + ], "cases-telemetry": [], "cases-user-actions": [ "action", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 05e1a48d55d2f..42f45ac418f82 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -669,6 +669,27 @@ } } }, + "cases-rules": { + "dynamic": false, + "properties": { + "counter": { + "type": "unsigned_long" + }, + "createdAt": { + "type": "date" + }, + "rules": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "updatedAt": { + "type": "date" + } + } + }, "cases-telemetry": { "dynamic": false, "properties": {} @@ -1568,11 +1589,11 @@ "assetType": { "type": "keyword" }, - "dashboardSavedObjectId": { - "type": "keyword" - }, "dashboardFilterAssetIdEnabled": { "type": "boolean" + }, + "dashboardSavedObjectId": { + "type": "keyword" } } }, 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 4379a2d35eeb0..c44a06f066876 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 @@ -74,6 +74,7 @@ describe('checking migration metadata changes on all registered SO types', () => "cases-comments": "5cb0a421588831c2a950e50f486048b8aabbae25", "cases-configure": "44ed7b8e0f44df39516b8870589b89e32224d2bf", "cases-connector-mappings": "f9d1ac57e484e69506c36a8051e4d61f4a8cfd25", + "cases-rules": "6d1776f5c46a99e1a0f3085c537146c1cdfbc829", "cases-telemetry": "f219eb7e26772884342487fc9602cfea07b3cedc", "cases-user-actions": "483f10db9b3bd1617948d7032a98b7791bf87414", "cloud-security-posture-settings": "e0f61c68bbb5e4cfa46ce8994fa001e417df51ca", 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 7e41abff9b024..ecdc45dd31d22 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 @@ -34,6 +34,7 @@ const previouslyRegisteredTypes = [ 'cases-comments', 'cases-configure', 'cases-connector-mappings', + 'cases-rules', 'cases-sub-case', 'cases-user-actions', 'cases-telemetry', 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 0c0717d6e7278..1a1620f1f7b3a 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 @@ -195,6 +195,7 @@ describe('split .kibana index into multiple system indices', () => { "cases-comments", "cases-configure", "cases-connector-mappings", + "cases-rules", "cases-telemetry", "cases-user-actions", "cloud-security-posture-settings", diff --git a/x-pack/plugins/actions/server/create_system_actions.test.ts b/x-pack/plugins/actions/server/create_system_actions.test.ts index 55b7d43bf5631..fde7bed65ab61 100644 --- a/x-pack/plugins/actions/server/create_system_actions.test.ts +++ b/x-pack/plugins/actions/server/create_system_actions.test.ts @@ -36,7 +36,7 @@ describe('createSystemConnectors', () => { { id: 'system-connector-system-action-type-2', actionTypeId: 'system-action-type-2', - name: 'System action: system-action-type-2', + name: 'My system action type', secrets: {}, config: {}, isDeprecated: false, diff --git a/x-pack/plugins/actions/server/create_system_actions.ts b/x-pack/plugins/actions/server/create_system_actions.ts index f6079ea940222..604b5589faa47 100644 --- a/x-pack/plugins/actions/server/create_system_actions.ts +++ b/x-pack/plugins/actions/server/create_system_actions.ts @@ -14,7 +14,7 @@ export const createSystemConnectors = (actionTypes: ActionType[]): InMemoryConne const systemConnectors: InMemoryConnector[] = systemActionTypes.map((systemActionType) => ({ id: `system-connector-${systemActionType.id}`, actionTypeId: systemActionType.id, - name: `System action: ${systemActionType.id}`, + name: systemActionType.name, isMissingSecrets: false, config: {}, secrets: {}, diff --git a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap index f85e0abce89c7..e20376ff26420 100644 --- a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap +++ b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -635,6 +635,379 @@ Object { } `; +exports[`Connector type config checks detect connector type changes for: .cases 1`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "alerts": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "metas": Array [ + Object { + "x-oas-get-additional-properties": [Function], + }, + ], + "rules": Array [ + Object { + "args": Object { + "key": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "value": Object { + "flags": Object { + "error": [Function], + }, + "type": "any", + }, + }, + "name": "entries", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "record", + }, + ], + "type": "array", + }, + "groupingBy": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "rules": Array [ + Object { + "args": Object { + "limit": 0, + }, + "name": "min", + }, + Object { + "args": Object { + "limit": 1, + }, + "name": "max", + }, + ], + "type": "array", + }, + "maximumCasesToOpen": Object { + "flags": Object { + "default": 5, + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "limit": 1, + }, + "name": "min", + }, + Object { + "args": Object { + "limit": 10, + }, + "name": "max", + }, + ], + "type": "number", + }, + "owner": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "reopenClosedCases": Object { + "flags": Object { + "default": false, + "error": [Function], + "presence": "optional", + }, + "type": "boolean", + }, + "rule": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "id": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "name": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "ruleUrl": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", + }, + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", + }, + }, + ], + "type": "alternatives", + }, + "tags": Object { + "flags": Object { + "default": Array [], + "error": [Function], + "presence": "optional", + }, + "items": Array [ + Object { + "flags": Object { + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + ], + "type": "array", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + "timeWindow": Object { + "flags": Object { + "default": "7d", + "error": [Function], + "presence": "optional", + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .cases 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .cases 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .cases 4`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + exports[`Connector type config checks detect connector type changes for: .cases-webhook 1`] = ` Object { "flags": Object { diff --git a/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts b/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts index 473f5b72ce59b..0b3d1043b2055 100644 --- a/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts +++ b/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts @@ -29,4 +29,5 @@ export const connectorTypes: string[] = [ '.bedrock', '.d3security', '.sentinelone', + '.cases', ]; diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 4d589699a2caa..9737afdb095c0 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -263,7 +263,7 @@ describe('Actions Plugin', () => { { id: 'system-connector-.cases', actionTypeId: '.cases', - name: 'System action: .cases', + name: 'Cases', config: {}, secrets: {}, isDeprecated: false, @@ -769,7 +769,7 @@ describe('Actions Plugin', () => { { id: 'system-connector-.cases', actionTypeId: '.cases', - name: 'System action: .cases', + name: 'Cases', config: {}, secrets: {}, isDeprecated: false, diff --git a/x-pack/plugins/actions/server/sub_action_framework/executor.ts b/x-pack/plugins/actions/server/sub_action_framework/executor.ts index 9ac7a63dc2e96..2a68a060e15c4 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/executor.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/executor.ts @@ -29,7 +29,7 @@ export const buildExecutor = < logger: Logger; configurationUtilities: ActionsConfigurationUtilities; }): ExecutorType => { - return async ({ actionId, params, config, secrets, services }) => { + return async ({ actionId, params, config, secrets, services, request }) => { const subAction = params.subAction; const subActionParams = params.subActionParams; @@ -40,6 +40,7 @@ export const buildExecutor = < configurationUtilities, logger, services, + request, }); const subActions = service.getSubActions(); diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts index 7d3c6e51e844e..3d7e6540fd74f 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts @@ -21,6 +21,7 @@ import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import { finished } from 'stream/promises'; import { IncomingMessage } from 'http'; import { PassThrough } from 'stream'; +import { KibanaRequest } from '@kbn/core-http-server'; import { assertURL } from './helpers/validators'; import { ActionsConfigurationUtilities } from '../actions_config'; import { SubAction, SubActionRequestParams } from './types'; @@ -39,6 +40,7 @@ export abstract class SubActionConnector { private axiosInstance: AxiosInstance; private subActions: Map = new Map(); private configurationUtilities: ActionsConfigurationUtilities; + protected readonly kibanaRequest?: KibanaRequest; protected logger: Logger; protected esClient: ElasticsearchClient; protected savedObjectsClient: SavedObjectsClientContract; @@ -55,6 +57,7 @@ export abstract class SubActionConnector { this.esClient = params.services.scopedClusterClient; this.configurationUtilities = params.configurationUtilities; this.axiosInstance = axios.create(); + this.kibanaRequest = params.request; } private normalizeURL(url: string) { diff --git a/x-pack/plugins/actions/server/sub_action_framework/types.ts b/x-pack/plugins/actions/server/sub_action_framework/types.ts index c184eaf6f5d88..a95f25edee49f 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/types.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/types.ts @@ -10,6 +10,7 @@ import type { Logger } from '@kbn/logging'; import type { LicenseType } from '@kbn/licensing-plugin/common/types'; import type { Method, AxiosRequestConfig } from 'axios'; +import { KibanaRequest } from '@kbn/core-http-server'; import type { ActionsConfigurationUtilities } from '../actions_config'; import type { ActionTypeParams, @@ -30,6 +31,7 @@ export interface ServiceParams { logger: Logger; secrets: Secrets; services: Services; + request?: KibanaRequest; } export type SubActionRequestParams = { diff --git a/x-pack/plugins/actions/tsconfig.json b/x-pack/plugins/actions/tsconfig.json index aae2d31c7aa09..76ca916bc0ad0 100644 --- a/x-pack/plugins/actions/tsconfig.json +++ b/x-pack/plugins/actions/tsconfig.json @@ -44,6 +44,7 @@ "@kbn/core-logging-server-mocks", "@kbn/serverless", "@kbn/actions-types", + "@kbn/core-http-server", "@kbn/core-test-helpers-kbn-server", "@kbn/security-plugin-types-server" ], diff --git a/x-pack/plugins/alerting/server/connector_adapters/types.ts b/x-pack/plugins/alerting/server/connector_adapters/types.ts index 4a9ab393270b7..e33fe1613be68 100644 --- a/x-pack/plugins/alerting/server/connector_adapters/types.ts +++ b/x-pack/plugins/alerting/server/connector_adapters/types.ts @@ -9,7 +9,7 @@ import { ObjectType } from '@kbn/config-schema'; import type { RuleTypeParams, SanitizedRule } from '../../common'; import { CombinedSummarizedAlerts } from '../types'; -type Rule = Pick, 'id' | 'name' | 'tags'>; +type Rule = Pick, 'id' | 'name' | 'tags' | 'consumer'>; export interface ConnectorAdapterParams { [x: string]: unknown; diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts index 5f5a97c842e33..47022933e93a1 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts @@ -115,6 +115,7 @@ const rule = { uuid: '111-111', }, ], + consumer: 'test-consumer', } as unknown as SanitizedRule; const defaultExecutionParams = { @@ -2472,6 +2473,7 @@ describe('Execution Handler', () => { id: rule.id, name: rule.name, tags: rule.tags, + consumer: 'test-consumer', }, ruleUrl: 'https://example.com/s/test1/app/management/insightsAndAlerting/triggersActions/rule/1', diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts index f794133c69dc7..e268162b88f1b 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -459,7 +459,7 @@ export class ExecutionHandler< const connectorAdapterActionParams = connectorAdapter.buildActionParams({ alerts: summarizedAlerts, - rule: { id: rule.id, tags: rule.tags, name: rule.name }, + rule: { id: rule.id, tags: rule.tags, name: rule.name, consumer: rule.consumer }, ruleUrl: ruleUrl?.absoluteUrl, spaceId, params: action.params, diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index b4a21607a293c..6b6b914826b61 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -24,6 +24,7 @@ export const CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT = 'cases-connector-mappings' a export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions' as const; export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments' as const; export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure' as const; +export const CASE_RULES_SAVED_OBJECT = 'cases-rules' as const; /** * If more values are added here please also add them here: x-pack/test/cases_api_integration/common/plugins @@ -210,7 +211,15 @@ export const LOCAL_STORAGE_KEYS = { * Connectors */ +export enum CASES_CONNECTOR_SUB_ACTION { + RUN = 'run', +} + export const NONE_CONNECTOR_ID: string = 'none'; +export const CASES_CONNECTOR_ID = '.cases'; +export const CASES_CONNECTOR_TITLE = 'Cases'; + +export const CASES_CONNECTOR_TIME_WINDOW_REGEX = '^[1-9][0-9]*[d,w]$'; /** * This field is used for authorization of the entities within the cases plugin. Each entity within Cases will have the owner field diff --git a/x-pack/plugins/cases/common/constants/owner.test.ts b/x-pack/plugins/cases/common/constants/owner.test.ts new file mode 100644 index 0000000000000..07b6866f857a4 --- /dev/null +++ b/x-pack/plugins/cases/common/constants/owner.test.ts @@ -0,0 +1,26 @@ +/* + * 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 { AlertConsumers } from '@kbn/rule-data-utils'; +import { OWNER_INFO } from './owners'; + +describe('OWNER_INFO', () => { + it('should use all available rule consumers', () => { + const allConsumers = new Set(Object.values(AlertConsumers)); + const ownersMappingConsumers = new Set( + Object.values(OWNER_INFO) + .map((value) => value.validRuleConsumers ?? []) + .flat() + ); + + expect(allConsumers.size).toEqual(ownersMappingConsumers.size); + + for (const consumer of allConsumers) { + expect(ownersMappingConsumers.has(consumer)).toBe(true); + } + }); +}); diff --git a/x-pack/plugins/cases/common/constants/owners.ts b/x-pack/plugins/cases/common/constants/owners.ts index 3e799030c7d5b..8ac7164ef75cc 100644 --- a/x-pack/plugins/cases/common/constants/owners.ts +++ b/x-pack/plugins/cases/common/constants/owners.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { AlertConsumers } from '@kbn/rule-data-utils'; import { APP_ID } from './application'; import type { Owner } from './types'; @@ -23,6 +24,7 @@ interface RouteInfo { label: string; iconType: string; appRoute: string; + validRuleConsumers?: readonly AlertConsumers[]; } export const OWNER_INFO: Record = { @@ -32,6 +34,7 @@ export const OWNER_INFO: Record = { label: 'Security', iconType: 'logoSecurity', appRoute: '/app/security', + validRuleConsumers: [AlertConsumers.SIEM], }, [OBSERVABILITY_OWNER]: { id: OBSERVABILITY_OWNER, @@ -39,6 +42,16 @@ export const OWNER_INFO: Record = { label: 'Observability', iconType: 'logoObservability', appRoute: '/app/observability', + validRuleConsumers: [ + // only valid in serverless + AlertConsumers.OBSERVABILITY, + AlertConsumers.APM, + AlertConsumers.INFRASTRUCTURE, + AlertConsumers.LOGS, + AlertConsumers.SLO, + AlertConsumers.UPTIME, + AlertConsumers.MONITORING, + ], }, [GENERAL_CASES_OWNER]: { id: GENERAL_CASES_OWNER, @@ -46,5 +59,6 @@ export const OWNER_INFO: Record = { label: 'Stack', iconType: 'casesApp', appRoute: '/app/management/insightsAndAlerting', + validRuleConsumers: [AlertConsumers.ML, AlertConsumers.STACK_ALERTS, AlertConsumers.EXAMPLE], }, } as const; diff --git a/x-pack/plugins/cases/kibana.jsonc b/x-pack/plugins/cases/kibana.jsonc index cd4fc787af2e0..feecbc66ee445 100644 --- a/x-pack/plugins/cases/kibana.jsonc +++ b/x-pack/plugins/cases/kibana.jsonc @@ -12,6 +12,7 @@ "cases" ], "requiredPlugins": [ + "alerting", "actions", "data", "embeddable", diff --git a/x-pack/plugins/cases/public/components/system_actions/cases/cases.test.tsx b/x-pack/plugins/cases/public/components/system_actions/cases/cases.test.tsx new file mode 100644 index 0000000000000..e468807e3db92 --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/cases/cases.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { getConnectorType } from './cases'; +const CONNECTOR_TYPE_ID = '.cases'; +let connectorTypeModel: ActionTypeModel; + +beforeAll(() => { + connectorTypeModel = getConnectorType(); +}); + +describe('has correct connector id', () => { + test('connector type static data is as expected', () => { + expect(connectorTypeModel.id).toEqual(CONNECTOR_TYPE_ID); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', async () => { + const actionParams = { + subActionParams: { + timeWindow: '7d', + reopenClosedCases: false, + groupingBy: [], + owner: 'cases', + }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { timeWindow: [] }, + }); + }); + + test('params validation succeeds when valid timeWindow', async () => { + const actionParams = { subActionParams: { timeWindow: '17w' } }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { timeWindow: [] }, + }); + }); + + test('params validation fails when timeWindow is empty', async () => { + const actionParams = { subActionParams: { timeWindow: '' } }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { timeWindow: ['Invalid time window.'] }, + }); + }); + + test('params validation fails when timeWindow is undefined', async () => { + const actionParams = { subActionParams: { timeWindow: undefined } }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { timeWindow: ['Invalid time window.'] }, + }); + }); + + test('params validation fails when timeWindow is null', async () => { + const actionParams = { subActionParams: { timeWindow: null } }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { timeWindow: ['Invalid time window.'] }, + }); + }); + + test('params validation fails when timeWindow size is 0', async () => { + const actionParams = { subActionParams: { timeWindow: '0d' } }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { timeWindow: ['Invalid time window.'] }, + }); + }); + + test('params validation fails when timeWindow size is negative', async () => { + const actionParams = { subActionParams: { timeWindow: '-5w' } }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { timeWindow: ['Invalid time window.'] }, + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/system_actions/cases/cases.tsx b/x-pack/plugins/cases/public/components/system_actions/cases/cases.tsx new file mode 100644 index 0000000000000..0506b2069a3c4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/cases/cases.tsx @@ -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 { lazy } from 'react'; +import type { + GenericValidationResult, + ActionTypeModel as ConnectorTypeModel, +} from '@kbn/triggers-actions-ui-plugin/public'; + +import { + CASES_CONNECTOR_ID, + CASES_CONNECTOR_TITLE, + CASES_CONNECTOR_TIME_WINDOW_REGEX, +} from '../../../../common/constants'; +import type { CasesActionParams } from './types'; +import * as i18n from './translations'; + +interface ValidationErrors { + timeWindow: string[]; +} + +export function getConnectorType(): ConnectorTypeModel<{}, {}, CasesActionParams> { + return { + id: CASES_CONNECTOR_ID, + iconClass: 'casesApp', + selectMessage: i18n.CASE_ACTION_DESC, + actionTypeTitle: CASES_CONNECTOR_TITLE, + actionConnectorFields: null, + isExperimental: true, + validateParams: async ( + actionParams: CasesActionParams + ): Promise> => { + const errors: ValidationErrors = { + timeWindow: [], + }; + const validationResult = { + errors, + }; + const timeWindowRegex = new RegExp(CASES_CONNECTOR_TIME_WINDOW_REGEX, 'g'); + + if ( + actionParams.subActionParams && + (!actionParams.subActionParams.timeWindow || + !actionParams.subActionParams.timeWindow.length || + !timeWindowRegex.test(actionParams.subActionParams.timeWindow)) + ) { + errors.timeWindow.push(i18n.TIME_WINDOW_SIZE_ERROR); + } + return validationResult; + }, + actionParamsFields: lazy(() => import('./cases_params')), + isSystemActionType: true, + }; +} diff --git a/x-pack/plugins/cases/public/components/system_actions/cases/cases_params.test.tsx b/x-pack/plugins/cases/public/components/system_actions/cases/cases_params.test.tsx new file mode 100644 index 0000000000000..e43eb7a253026 --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/cases/cases_params.test.tsx @@ -0,0 +1,233 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { ActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useApplication } from '../../../common/lib/kibana/use_application'; +import { useAlertDataViews } from '../hooks/use_alert_data_view'; +import { CasesParamsFields } from './cases_params'; +import { showEuiComboBoxOptions } from '@elastic/eui/lib/test/rtl'; + +jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); +jest.mock('../../../common/lib/kibana/use_application'); +jest.mock('../hooks/use_alert_data_view'); + +const useAlertDataViewsMock = useAlertDataViews as jest.Mock; +const useApplicationMock = useApplication as jest.Mock; + +const actionParams = { + subAction: 'run', + subActionParams: { + timeWindow: '6w', + reopenClosedCases: false, + groupingBy: [], + }, +}; + +const connector: ActionConnector = { + id: 'test', + actionTypeId: '.test', + name: 'Test', + isPreconfigured: false, + isDeprecated: false, + isSystemAction: true as const, +}; +const editAction = jest.fn(); +const defaultProps = { + actionConnector: connector, + actionParams, + editAction, + errors: { 'subActionParams.timeWindow.size': [] }, + index: 0, + producerId: 'test', +}; + +describe('CasesParamsFields renders', () => { + beforeEach(() => { + jest.clearAllMocks(); + useApplicationMock.mockReturnValueOnce({ appId: 'management' }); + useAlertDataViewsMock.mockReturnValue({ + loading: false, + dataViews: [ + { + title: '.alerts-test', + fields: [ + { + name: 'host.ip', + type: 'ip', + aggregatable: true, + }, + { + name: 'host.geo.location', + type: 'geo_point', + }, + ], + }, + ], + }); + }); + + it('all params fields are rendered', async () => { + render(); + + expect(await screen.findByTestId('group-by-alert-field-combobox')).toBeInTheDocument(); + expect(await screen.findByTestId('time-window-size-input')).toBeInTheDocument(); + expect(await screen.findByTestId('time-window-unit-select')).toBeInTheDocument(); + expect(await screen.findByTestId('reopen-case')).toBeInTheDocument(); + }); + + it('renders loading state of grouping by fields correctly', async () => { + useAlertDataViewsMock.mockReturnValue({ loading: true }); + render(); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + }); + + it('disables dropdown when loading grouping by fields', async () => { + useAlertDataViewsMock.mockReturnValue({ loading: true }); + render(); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + expect(await screen.findByTestId('comboBoxSearchInput')).toBeDisabled(); + }); + + it('when subAction undefined, sets to default', () => { + const newProps = { + ...defaultProps, + actionParams: {}, + }; + render(); + + expect(editAction.mock.calls[0][1]).toEqual('run'); + }); + + it('when subActionParams undefined, sets to default', () => { + const newProps = { + ...defaultProps, + actionParams: { + subAction: 'run', + }, + }; + render(); + expect(editAction.mock.calls[0][1]).toEqual({ + timeWindow: '7d', + reopenClosedCases: false, + groupingBy: [], + }); + }); + + it('If timeWindow has errors, form row is invalid', async () => { + const newProps = { + ...defaultProps, + errors: { timeWindow: ['error'] }, + }; + + render(); + + expect(await screen.findByText('error')).toBeInTheDocument(); + }); + + describe('UI updates', () => { + it('renders grouping by field options', async () => { + render(); + + userEvent.click(await screen.findByTestId('group-by-alert-field-combobox')); + + await showEuiComboBoxOptions(); + + expect(await screen.findByText('host.ip')).toBeInTheDocument(); + + expect(screen.queryByText('host.geo.location')).not.toBeInTheDocument(); + }); + + it('updates grouping by field', async () => { + render(); + + userEvent.click(await screen.findByTestId('group-by-alert-field-combobox')); + + await showEuiComboBoxOptions(); + + expect(await screen.findByText('host.ip')).toBeInTheDocument(); + + userEvent.click(await screen.findByText('host.ip')); + + expect(editAction.mock.calls[0][1].groupingBy).toEqual(['host.ip']); + }); + + it('updates grouping by field by search', async () => { + useAlertDataViewsMock.mockReturnValue({ + loading: false, + dataViews: [ + { + title: '.alerts-test', + fields: [ + { + name: 'host.ip', + type: 'ip', + aggregatable: true, + }, + { + name: 'host.geo.location', + type: 'geo_point', + }, + { + name: 'alert.name', + type: 'string', + aggregatable: true, + }, + ], + }, + ], + }); + + render(); + + userEvent.click(await screen.findByTestId('group-by-alert-field-combobox')); + + await showEuiComboBoxOptions(); + + userEvent.type(await screen.findByTestId('comboBoxSearchInput'), 'alert.name{enter}'); + + expect(editAction.mock.calls[0][1].groupingBy).toEqual(['alert.name']); + }); + + it('updates time window size', async () => { + render(); + + expect(await screen.findByTestId('time-window-size-input')).toBeInTheDocument(); + + userEvent.clear(await screen.findByTestId('time-window-size-input')); + userEvent.paste(await screen.findByTestId('time-window-size-input'), '5'); + + expect(editAction.mock.calls[0][1].timeWindow).toEqual('5w'); + }); + + it('updates time window unit', async () => { + render(); + + expect(await screen.findByTestId('time-window-unit-select')).toBeInTheDocument(); + + fireEvent.change(await screen.findByTestId('time-window-unit-select'), { + target: { value: 'd' }, + }); + + expect(editAction.mock.calls[0][1].timeWindow).toEqual('6d'); + }); + + it('updates reopenClosedCases', async () => { + render(); + + expect(await screen.findByTestId('reopen-case')).toBeInTheDocument(); + + userEvent.click(await screen.findByTestId('reopen-case')); + + expect(editAction.mock.calls[0][1].reopenClosedCases).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/system_actions/cases/cases_params.tsx b/x-pack/plugins/cases/public/components/system_actions/cases/cases_params.tsx new file mode 100644 index 0000000000000..b7542ab90df84 --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/cases/cases_params.tsx @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useEffect, useMemo } from 'react'; + +import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public/types'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import { + EuiCheckbox, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiComboBox, +} from '@elastic/eui'; +import type { ValidFeatureId } from '@kbn/rule-data-utils'; +import { CASES_CONNECTOR_SUB_ACTION } from '../../../../common/constants'; +import * as i18n from './translations'; +import type { CasesActionParams } from './types'; +import { DEFAULT_TIME_WINDOW, TIME_UNITS } from './constants'; +import { getTimeUnitOptions } from './utils'; +import { useAlertDataViews } from '../hooks/use_alert_data_view'; + +export const CasesParamsFieldsComponent: React.FunctionComponent< + ActionParamsProps +> = ({ actionParams, editAction, errors, index, producerId }) => { + const { dataViews, loading: loadingAlertDataViews } = useAlertDataViews( + producerId ? [producerId as ValidFeatureId] : [] + ); + + const { timeWindow, reopenClosedCases, groupingBy } = useMemo( + () => + actionParams.subActionParams ?? { + timeWindow: `${DEFAULT_TIME_WINDOW}`, + reopenClosedCases: false, + groupingBy: [], + }, + [actionParams.subActionParams] + ); + + const parsedTimeWindowSize = timeWindow.slice(0, timeWindow.length - 1); + const parsedTimeWindowUnit = timeWindow.slice(-1); + + const timeWindowSize = isNaN(parseInt(parsedTimeWindowSize, 10)) + ? DEFAULT_TIME_WINDOW[0] + : parsedTimeWindowSize.toString(); + + const timeWindowUnit = Object.values(TIME_UNITS).includes(parsedTimeWindowUnit as TIME_UNITS) + ? parsedTimeWindowUnit + : DEFAULT_TIME_WINDOW[1]; + + useEffect(() => { + if (!actionParams.subAction) { + editAction('subAction', CASES_CONNECTOR_SUB_ACTION.RUN, index); + } + + if (!actionParams.subActionParams) { + editAction( + 'subActionParams', + { + timeWindow: `${DEFAULT_TIME_WINDOW}`, + reopenClosedCases: false, + groupingBy: [], + }, + index + ); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionParams]); + + const editSubActionProperty = useCallback( + (key: string, value: unknown) => { + return editAction( + 'subActionParams', + { ...actionParams.subActionParams, [key]: value }, + index + ); + }, + [editAction, index, actionParams.subActionParams] + ); + + const handleTimeWindowChange = useCallback( + (key: 'timeWindowSize' | 'timeWindowUnit', value: string) => { + if (!value) { + return; + } + + const newTimeWindow = + key === 'timeWindowSize' ? `${value}${timeWindowUnit}` : `${timeWindowSize}${value}`; + + editSubActionProperty('timeWindow', newTimeWindow); + }, + [editSubActionProperty, timeWindowUnit, timeWindowSize] + ); + + const onChangeComboBox = useCallback( + (optionsValue: Array>) => { + editSubActionProperty('groupingBy', optionsValue?.length ? [optionsValue[0].value] : []); + }, + [editSubActionProperty] + ); + + const options: Array> = useMemo(() => { + if (!dataViews?.length) { + return []; + } + + return dataViews + .map((dataView) => { + return dataView.fields + .filter((field) => Boolean(field.aggregatable)) + .map((field) => ({ + value: field.name, + label: field.name, + })); + }) + .flat(); + }, [dataViews]); + + const selectedOptions = groupingBy.map((field) => ({ value: field, label: field })); + + return ( + <> + + + + + + + + + 0 && + timeWindow !== undefined + } + > + + + { + handleTimeWindowChange('timeWindowSize', e.target.value); + }} + /> + + + { + handleTimeWindowChange('timeWindowUnit', e.target.value); + }} + options={getTimeUnitOptions(timeWindowSize)} + /> + + + + + + + { + editSubActionProperty('reopenClosedCases', e.target.checked); + }} + /> + + + + ); +}; + +CasesParamsFieldsComponent.displayName = 'CasesParamsFields'; + +export const CasesParamsFields = memo(CasesParamsFieldsComponent); + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export { CasesParamsFields as default }; diff --git a/x-pack/plugins/cases/public/components/system_actions/cases/constants.ts b/x-pack/plugins/cases/public/components/system_actions/cases/constants.ts new file mode 100644 index 0000000000000..18605ff9525cf --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/cases/constants.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. + */ + +export const DEFAULT_TIME_WINDOW = '7d'; + +export enum TIME_UNITS { + DAYS = 'd', + WEEKS = 'w', +} diff --git a/x-pack/plugins/cases/public/components/system_actions/cases/translations.ts b/x-pack/plugins/cases/public/components/system_actions/cases/translations.ts new file mode 100644 index 0000000000000..012c5c6fe681c --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/cases/translations.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 { i18n } from '@kbn/i18n'; + +export const CASE_ACTION_DESC = i18n.translate( + 'xpack.cases.systemActions.casesConnector.selectMessageText', + { + defaultMessage: 'Create a case in Kibana.', + } +); + +export const GROUP_BY_ALERT = i18n.translate( + 'xpack.cases.systemActions.casesConnector.groupByLabel', + { + defaultMessage: 'Group by alert field', + } +); + +export const TIME_WINDOW = i18n.translate( + 'xpack.cases.systemActions.casesConnector.timeWindowLabel', + { + defaultMessage: 'Time window', + } +); + +export const TIME_WINDOW_SIZE_ERROR = i18n.translate( + 'xpack.cases.systemActions.casesConnector.timeWindowSizeError', + { + defaultMessage: 'Invalid time window.', + } +); + +export const REOPEN_WHEN_CASE_IS_CLOSED = i18n.translate( + 'xpack.cases.systemActions.casesConnector.reopenWhenCaseIsClosed', + { + defaultMessage: 'Reopen when the case is closed', + } +); + +export const DAYS = (timeValue: string) => + i18n.translate('xpack.cases.systemActions.casesConnector.daysLabel', { + defaultMessage: '{timeValue, plural, one {day} other {days}}', + values: { timeValue }, + }); + +export const YEARS = (timeValue: string) => + i18n.translate('xpack.cases.systemActions.casesConnector.yearsLabel', { + defaultMessage: '{timeValue, plural, one {year} other {years}}', + values: { timeValue }, + }); + +export const MONTHS = (timeValue: string) => + i18n.translate('xpack.cases.systemActions.casesConnector.monthsLabel', { + defaultMessage: '{timeValue, plural, one {month} other {months}}', + values: { timeValue }, + }); + +export const WEEKS = (timeValue: string) => + i18n.translate('xpack.cases.systemActions.casesConnector.weeksLabel', { + defaultMessage: '{timeValue, plural, one {week} other {weeks}}', + values: { timeValue }, + }); diff --git a/x-pack/plugins/cases/public/components/system_actions/cases/types.ts b/x-pack/plugins/cases/public/components/system_actions/cases/types.ts new file mode 100644 index 0000000000000..cea6110817f40 --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/cases/types.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 interface CasesSubActionParamsUI { + timeWindow: string; + reopenClosedCases: boolean; + groupingBy: string[]; +} +export interface CasesActionParams { + subAction: string; + subActionParams: CasesSubActionParamsUI; +} diff --git a/x-pack/plugins/cases/public/components/system_actions/cases/utils.test.ts b/x-pack/plugins/cases/public/components/system_actions/cases/utils.test.ts new file mode 100644 index 0000000000000..3195a459b98d8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/cases/utils.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getTimeUnitOptions } from './utils'; + +describe('getTimeUnitOptions', () => { + test('return single unit time options', () => { + const timeUnitValue = getTimeUnitOptions('1'); + expect(timeUnitValue).toMatchObject([ + { text: 'day', value: 'd' }, + { text: 'week', value: 'w' }, + ]); + }); + + test('return multiple unit time options', () => { + const timeUnitValue = getTimeUnitOptions('10'); + expect(timeUnitValue).toMatchObject([ + { text: 'days', value: 'd' }, + { text: 'weeks', value: 'w' }, + ]); + }); + + test('return correct unit time options for 0', () => { + const timeUnitValue = getTimeUnitOptions('0'); + expect(timeUnitValue).toMatchObject([ + { text: 'days', value: 'd' }, + { text: 'weeks', value: 'w' }, + ]); + }); + + test('return correct unit time options for negative size', () => { + const timeUnitValue = getTimeUnitOptions('-5'); + expect(timeUnitValue).toMatchObject([ + { text: 'days', value: 'd' }, + { text: 'weeks', value: 'w' }, + ]); + }); + + test('return correct unit time options for empty string', () => { + const timeUnitValue = getTimeUnitOptions(''); + expect(timeUnitValue).toMatchObject([ + { text: 'days', value: 'd' }, + { text: 'weeks', value: 'w' }, + ]); + }); +}); diff --git a/x-pack/plugins/cases/public/components/system_actions/cases/utils.ts b/x-pack/plugins/cases/public/components/system_actions/cases/utils.ts new file mode 100644 index 0000000000000..6b2139e5b9913 --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/cases/utils.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TIME_UNITS } from './constants'; +import * as i18n from './translations'; + +export const getTimeUnitOptions = (unitSize: string) => { + return Object.entries(TIME_UNITS).map(([_key, value]) => { + return { + text: getTimeUnitLabels(value, unitSize === '' ? '0' : unitSize), + value, + }; + }); +}; + +export const getTimeUnitLabels = (timeUnit = TIME_UNITS.DAYS, timeValue = '0') => { + switch (timeUnit) { + case TIME_UNITS.DAYS: + return i18n.DAYS(timeValue); + case TIME_UNITS.WEEKS: + return i18n.WEEKS(timeValue); + } +}; diff --git a/x-pack/plugins/cases/public/components/system_actions/hooks/alert_fields.ts b/x-pack/plugins/cases/public/components/system_actions/hooks/alert_fields.ts new file mode 100644 index 0000000000000..8a73674a5e264 --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/hooks/alert_fields.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ValidFeatureId } from '@kbn/rule-data-utils'; +import type { HttpSetup } from '@kbn/core/public'; +import type { FieldSpec } from '@kbn/data-views-plugin/common'; +import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common'; + +export async function fetchAlertFields({ + http, + featureIds, +}: { + http: HttpSetup; + featureIds: ValidFeatureId[]; +}): Promise { + const { fields: alertFields = [] } = await http.get<{ fields: FieldSpec[] }>( + `${BASE_RAC_ALERTS_API_PATH}/browser_fields`, + { + query: { featureIds }, + } + ); + return alertFields; +} diff --git a/x-pack/plugins/cases/public/components/system_actions/hooks/alert_index.ts b/x-pack/plugins/cases/public/components/system_actions/hooks/alert_index.ts new file mode 100644 index 0000000000000..4b5a496f226bb --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/hooks/alert_index.ts @@ -0,0 +1,25 @@ +/* + * 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 { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common'; +import type { HttpSetup } from '@kbn/core/public'; + +export async function fetchAlertIndexNames({ + http, + features, +}: { + http: HttpSetup; + features: string; +}): Promise { + const { index_name: indexNamesStr = [] } = await http.get<{ index_name: string[] }>( + `${BASE_RAC_ALERTS_API_PATH}/index`, + { + query: { features }, + } + ); + return indexNamesStr; +} diff --git a/x-pack/plugins/cases/public/components/system_actions/hooks/use_alert_data_view.test.tsx b/x-pack/plugins/cases/public/components/system_actions/hooks/use_alert_data_view.test.tsx new file mode 100644 index 0000000000000..8dbb501e4cf15 --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/hooks/use_alert_data_view.test.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks/dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AlertConsumers } from '@kbn/rule-data-utils'; +import type { ValidFeatureId } from '@kbn/rule-data-utils'; +import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock'; +import { useAlertDataViews } from './use_alert_data_view'; + +const mockUseKibanaReturnValue = createStartServicesMock(); + +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + __esModule: true, + useKibana: jest.fn(() => ({ + services: mockUseKibanaReturnValue, + })), +})); + +jest.mock('./alert_index', () => ({ + fetchAlertIndexNames: jest.fn(), +})); + +const { fetchAlertIndexNames } = jest.requireMock('./alert_index'); + +jest.mock('./alert_fields', () => ({ + fetchAlertFields: jest.fn(), +})); +const { fetchAlertFields } = jest.requireMock('./alert_fields'); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); +const wrapper = ({ children }: { children: Node }) => ( + {children} +); + +describe('useAlertDataView', () => { + const observabilityAlertFeatureIds: ValidFeatureId[] = [ + AlertConsumers.APM, + AlertConsumers.INFRASTRUCTURE, + AlertConsumers.LOGS, + AlertConsumers.UPTIME, + ]; + + beforeEach(() => { + fetchAlertIndexNames.mockResolvedValue([ + '.alerts-observability.uptime.alerts-*', + '.alerts-observability.metrics.alerts-*', + '.alerts-observability.logs.alerts-*', + '.alerts-observability.apm.alerts-*', + ]); + fetchAlertFields.mockResolvedValue([{ data: ' fields' }]); + }); + + afterEach(() => { + queryClient.clear(); + jest.clearAllMocks(); + }); + + it('initially is loading and does not have data', async () => { + const mockedAsyncDataView = { + loading: true, + dataview: undefined, + }; + + const { result } = renderHook(() => useAlertDataViews(observabilityAlertFeatureIds), { + wrapper, + }); + + await waitFor(() => expect(result.current).toEqual(mockedAsyncDataView)); + }); + + it('fetch index names + fields for the provided o11y featureIds', async () => { + renderHook(() => useAlertDataViews(observabilityAlertFeatureIds), { + wrapper, + }); + + await waitFor(() => expect(fetchAlertIndexNames).toHaveBeenCalledTimes(1)); + expect(fetchAlertFields).toHaveBeenCalledTimes(1); + }); + + it('only fetch index names for security featureId', async () => { + renderHook(() => useAlertDataViews([AlertConsumers.SIEM]), { + wrapper, + }); + + await waitFor(() => expect(fetchAlertIndexNames).toHaveBeenCalledTimes(1)); + expect(fetchAlertFields).toHaveBeenCalledTimes(0); + }); + + it('Do not fetch anything if security and o11y featureIds are mixed together', async () => { + const { result } = renderHook( + () => useAlertDataViews([AlertConsumers.SIEM, AlertConsumers.LOGS]), + { + wrapper, + } + ); + + await waitFor(() => + expect(result.current).toEqual({ + loading: false, + dataview: undefined, + }) + ); + expect(fetchAlertIndexNames).toHaveBeenCalledTimes(0); + expect(fetchAlertFields).toHaveBeenCalledTimes(0); + }); + + it('if fetch throws error return no data', async () => { + fetchAlertIndexNames.mockRejectedValue('error'); + + const { result } = renderHook(() => useAlertDataViews(observabilityAlertFeatureIds), { + wrapper, + }); + + await waitFor(() => + expect(result.current).toEqual({ + loading: false, + dataview: undefined, + }) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/system_actions/hooks/use_alert_data_view.ts b/x-pack/plugins/cases/public/components/system_actions/hooks/use_alert_data_view.ts new file mode 100644 index 0000000000000..863b07949af9d --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/hooks/use_alert_data_view.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { ValidFeatureId } from '@kbn/rule-data-utils'; +import { AlertConsumers } from '@kbn/rule-data-utils'; +import { useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { TriggersAndActionsUiServices } from '@kbn/triggers-actions-ui-plugin/public'; +import { fetchAlertIndexNames } from './alert_index'; +import { fetchAlertFields } from './alert_fields'; + +export interface UserAlertDataViews { + dataViews?: DataView[]; + loading: boolean; +} + +export function useAlertDataViews(featureIds: ValidFeatureId[]): UserAlertDataViews { + const { + http, + data: dataService, + notifications: { toasts }, + } = useKibana().services; + const [dataViews, setDataViews] = useState(undefined); + const features = featureIds.sort().join(','); + const isOnlySecurity = featureIds.length === 1 && featureIds.includes(AlertConsumers.SIEM); + + const hasSecurityAndO11yFeatureIds = + featureIds.length > 1 && featureIds.includes(AlertConsumers.SIEM); + + const hasNoSecuritySolution = + featureIds.length > 0 && !isOnlySecurity && !hasSecurityAndO11yFeatureIds; + + const queryIndexNameFn = () => { + return fetchAlertIndexNames({ http, features }); + }; + + const queryAlertFieldsFn = () => { + return fetchAlertFields({ http, featureIds }); + }; + + const onErrorFn = () => { + toasts.addDanger( + i18n.translate('xpack.cases.systemActions.useAlertDataView.useAlertDataMessage', { + defaultMessage: 'Unable to load alert data view', + }) + ); + }; + + const { + data: indexNames, + isSuccess: isIndexNameSuccess, + isInitialLoading: isIndexNameInitialLoading, + isLoading: isIndexNameLoading, + } = useQuery({ + queryKey: ['loadAlertIndexNames', features], + queryFn: queryIndexNameFn, + onError: onErrorFn, + refetchOnWindowFocus: false, + enabled: featureIds.length > 0 && !hasSecurityAndO11yFeatureIds, + }); + + const { + data: alertFields, + isSuccess: isAlertFieldsSuccess, + isInitialLoading: isAlertFieldsInitialLoading, + isLoading: isAlertFieldsLoading, + } = useQuery({ + queryKey: ['loadAlertFields', features], + queryFn: queryAlertFieldsFn, + onError: onErrorFn, + refetchOnWindowFocus: false, + enabled: hasNoSecuritySolution, + }); + + useEffect(() => { + return () => { + dataViews?.map((dv) => dataService.dataViews.clearInstanceCache(dv.id)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataViews]); + + // FUTURE ENGINEER this useEffect is for security solution user since + // we are using the user privilege to access the security alert index + useEffect(() => { + async function createDataView() { + const localDataview = await dataService?.dataViews.create({ + title: (indexNames ?? []).join(','), + allowNoIndex: true, + }); + setDataViews([localDataview]); + } + + if (isOnlySecurity && isIndexNameSuccess) { + createDataView(); + } + }, [dataService?.dataViews, indexNames, isIndexNameSuccess, isOnlySecurity]); + + // FUTURE ENGINEER this useEffect is for o11y and stack solution user since + // we are using the kibana user privilege to access the alert index + useEffect(() => { + if ( + indexNames && + alertFields && + !isOnlySecurity && + isAlertFieldsSuccess && + isIndexNameSuccess + ) { + setDataViews([ + { + title: (indexNames ?? []).join(','), + fieldFormatMap: {}, + fields: (alertFields ?? [])?.map((field) => { + return { + ...field, + ...(field.esTypes && field.esTypes.includes('flattened') ? { type: 'string' } : {}), + }; + }), + }, + ] as unknown as DataView[]); + } + }, [ + alertFields, + dataService?.dataViews, + indexNames, + isIndexNameSuccess, + isOnlySecurity, + isAlertFieldsSuccess, + ]); + + return useMemo( + () => ({ + dataViews, + loading: + featureIds.length === 0 || hasSecurityAndO11yFeatureIds + ? false + : isOnlySecurity + ? isIndexNameInitialLoading || isIndexNameLoading + : isIndexNameInitialLoading || + isIndexNameLoading || + isAlertFieldsInitialLoading || + isAlertFieldsLoading, + }), + [ + dataViews, + featureIds.length, + hasSecurityAndO11yFeatureIds, + isOnlySecurity, + isIndexNameInitialLoading, + isIndexNameLoading, + isAlertFieldsInitialLoading, + isAlertFieldsLoading, + ] + ); +} diff --git a/x-pack/plugins/cases/public/components/system_actions/index.ts b/x-pack/plugins/cases/public/components/system_actions/index.ts new file mode 100644 index 0000000000000..bcb57c130e5b4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/system_actions/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TriggersAndActionsUIPublicPluginSetup } from '@kbn/triggers-actions-ui-plugin/public'; + +import { getConnectorType } from './cases/cases'; + +export const registerSystemActions = (triggersActionsUi: TriggersAndActionsUIPublicPluginSetup) => + triggersActionsUi.actionTypeRegistry.register(getConnectorType()); diff --git a/x-pack/plugins/cases/public/plugin.test.ts b/x-pack/plugins/cases/public/plugin.test.ts index 03a17d5bf2cb0..bfe00078a04d6 100644 --- a/x-pack/plugins/cases/public/plugin.test.ts +++ b/x-pack/plugins/cases/public/plugin.test.ts @@ -52,6 +52,7 @@ describe('Cases Ui Plugin', () => { }, security: securityMock.createSetup(), management: managementPluginMock.createSetupContract(), + triggersActionsUi: triggersActionsUiMock.createStart(), }; pluginsStart = { diff --git a/x-pack/plugins/cases/public/plugin.ts b/x-pack/plugins/cases/public/plugin.ts index b9117746d87df..44393473767e6 100644 --- a/x-pack/plugins/cases/public/plugin.ts +++ b/x-pack/plugins/cases/public/plugin.ts @@ -36,6 +36,7 @@ import type { CasesPublicSetupDependencies, CasesPublicStartDependencies, } from './types'; +import { registerSystemActions } from './components/system_actions'; /** * @public @@ -113,6 +114,8 @@ export class CasesUiPlugin }); } + registerSystemActions(plugins.triggersActionsUi); + return { attachmentFramework: { registerExternalReference: (externalReferenceAttachmentType) => { diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index 91ca41f89aae2..7e3d5293e3674 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -18,7 +18,10 @@ import type { FeaturesPluginStart } from '@kbn/features-plugin/public'; import type { LensPublicStart } from '@kbn/lens-plugin/public'; import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; -import type { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart } from '@kbn/triggers-actions-ui-plugin/public'; +import type { + TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup, + TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, +} from '@kbn/triggers-actions-ui-plugin/public'; import type { DistributiveOmit } from '@elastic/eui'; import type { ApmBase } from '@elastic/apm-rum'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; @@ -64,6 +67,7 @@ export interface CasesPublicSetupDependencies { serverless?: ServerlessPluginSetup; management: ManagementSetup; home?: HomePublicPluginSetup; + triggersActionsUi: TriggersActionsSetup; } export interface CasesPublicStartDependencies { diff --git a/x-pack/plugins/cases/server/client/cases/update.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts similarity index 97% rename from x-pack/plugins/cases/server/client/cases/update.test.ts rename to x-pack/plugins/cases/server/client/cases/bulk_update.test.ts index c962b21be36d1..a3fc842dfe3e1 100644 --- a/x-pack/plugins/cases/server/client/cases/update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts @@ -19,7 +19,7 @@ import { } from '../../../common/constants'; import { mockCases } from '../../mocks'; import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; -import { update } from './update'; +import { bulkUpdate } from './bulk_update'; describe('update', () => { const cases = { @@ -55,7 +55,7 @@ describe('update', () => { }); it('notifies an assignee', async () => { - await update(cases, clientArgs, casesClientMock); + await bulkUpdate(cases, clientArgs, casesClientMock); expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ { @@ -72,7 +72,7 @@ describe('update', () => { expect.assertions(2); await expect( - update( + bulkUpdate( { cases: [ { @@ -104,7 +104,7 @@ describe('update', () => { ], }); - await expect(update(cases, clientArgs, casesClientMock)).rejects.toThrow( + await expect(bulkUpdate(cases, clientArgs, casesClientMock)).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: All update fields are identical to current version.' ); @@ -130,7 +130,7 @@ describe('update', () => { ], }); - await update( + await bulkUpdate( { cases: [ { @@ -172,7 +172,7 @@ describe('update', () => { saved_objects: [{ ...mockCases[0], attributes: { assignees: [{ uid: '1' }] } }], }); - await update( + await bulkUpdate( { cases: [ { @@ -211,7 +211,7 @@ describe('update', () => { ], }); - await update( + await bulkUpdate( { cases: [ { @@ -249,7 +249,7 @@ describe('update', () => { ], }); - await update( + await bulkUpdate( { cases: [ { @@ -273,7 +273,7 @@ describe('update', () => { it('should throw an error when an invalid field is included in the request payload', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -297,7 +297,7 @@ describe('update', () => { const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foo' }); await expect( - update( + bulkUpdate( { cases: [ { @@ -337,7 +337,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -355,7 +355,7 @@ describe('update', () => { it('does not update the category if the length is too long', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -375,7 +375,7 @@ describe('update', () => { it('throws error if category is just an empty string', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -395,7 +395,7 @@ describe('update', () => { it('throws error if category is a string with empty characters', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -414,7 +414,7 @@ describe('update', () => { }); it('should trim category', async () => { - await update( + await bulkUpdate( { cases: [ { @@ -471,7 +471,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -489,7 +489,7 @@ describe('update', () => { it('throws error if the title is too long', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -510,7 +510,7 @@ describe('update', () => { it('throws error if title is just an empty string', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -530,7 +530,7 @@ describe('update', () => { it('throws error if title is a string with empty characters', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -549,7 +549,7 @@ describe('update', () => { }); it('should trim title', async () => { - await update( + await bulkUpdate( { cases: [ { @@ -606,7 +606,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -628,7 +628,7 @@ describe('update', () => { .toString(); await expect( - update( + bulkUpdate( { cases: [ { @@ -648,7 +648,7 @@ describe('update', () => { it('throws error if description is just an empty string', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -668,7 +668,7 @@ describe('update', () => { it('throws error if description is a string with empty characters', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -687,7 +687,7 @@ describe('update', () => { }); it('should trim description', async () => { - await update( + await bulkUpdate( { cases: [ { @@ -750,7 +750,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -888,7 +888,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -910,7 +910,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -930,7 +930,7 @@ describe('update', () => { const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foo'); await expect( - update( + bulkUpdate( { cases: [ { @@ -954,7 +954,7 @@ describe('update', () => { .toString(); await expect( - update( + bulkUpdate( { cases: [ { @@ -974,7 +974,7 @@ describe('update', () => { it('throws error if tag is empty string', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -994,7 +994,7 @@ describe('update', () => { it('throws error if tag is a string with empty characters', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -1013,7 +1013,7 @@ describe('update', () => { }); it('should trim tags', async () => { - await update( + await bulkUpdate( { cases: [ { @@ -1106,7 +1106,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1156,7 +1156,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1213,7 +1213,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1264,7 +1264,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1284,7 +1284,7 @@ describe('update', () => { it('throws with duplicated customFields keys', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -1315,7 +1315,7 @@ describe('update', () => { it('throws when customFields keys are not present in configuration', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -1368,7 +1368,7 @@ describe('update', () => { ]); await expect( - update( + bulkUpdate( { cases: [ { @@ -1419,7 +1419,7 @@ describe('update', () => { ]); await expect( - update( + bulkUpdate( { cases: [ { @@ -1439,7 +1439,7 @@ describe('update', () => { it('throws when the customField types dont match the configuration', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -1481,7 +1481,7 @@ describe('update', () => { it(`throws an error when trying to update more than ${MAX_CASES_TO_UPDATE} cases`, async () => { await expect( - update( + bulkUpdate( { cases: Array(MAX_CASES_TO_UPDATE + 1).fill({ id: mockCases[0].id, @@ -1499,7 +1499,7 @@ describe('update', () => { it('throws an error when trying to update zero cases', async () => { await expect( - update( + bulkUpdate( { cases: [], }, @@ -1542,7 +1542,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1571,7 +1571,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1604,7 +1604,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.ts similarity index 99% rename from x-pack/plugins/cases/server/client/cases/update.ts rename to x-pack/plugins/cases/server/client/cases/bulk_update.ts index 420b352e44a30..b9984ac53b05e 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.ts @@ -308,7 +308,7 @@ export interface UpdateRequestWithOriginalCase { * * @ignore */ -export const update = async ( +export const bulkUpdate = async ( cases: CasesPatchRequest, clientArgs: CasesClientArgs, casesClient: CasesClient diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts index 4819ca3fc1672..68ee6f003f8b2 100644 --- a/x-pack/plugins/cases/server/client/cases/client.ts +++ b/x-pack/plugins/cases/server/client/cases/client.ts @@ -32,7 +32,7 @@ import type { CasesByAlertIDParams, GetParams } from './get'; import { get, resolve, getCasesByAlertID, getReporters, getTags, getCategories } from './get'; import type { PushParams } from './push'; import { push } from './push'; -import { update } from './update'; +import { bulkUpdate } from './bulk_update'; import { bulkCreate } from './bulk_create'; import type { ReplaceCustomFieldArgs } from './replace_custom_field'; import { replaceCustomField } from './replace_custom_field'; @@ -75,7 +75,7 @@ export interface CasesSubClient { /** * Update the specified cases with the passed in values. */ - update(cases: CasesPatchRequest): Promise; + bulkUpdate(cases: CasesPatchRequest): Promise; /** * Delete a case and all its comments. * @@ -122,7 +122,7 @@ export const createCasesSubClient = ( resolve: (params: GetParams) => resolve(params, clientArgs), bulkGet: (params) => bulkGet(params, clientArgs), push: (params: PushParams) => push(params, clientArgs, casesClient), - update: (cases: CasesPatchRequest) => update(cases, clientArgs, casesClient), + bulkUpdate: (cases: CasesPatchRequest) => bulkUpdate(cases, clientArgs, casesClient), delete: (ids: string[]) => deleteCases(ids, clientArgs), getTags: (params: AllTagsFindRequest) => getTags(params, clientArgs), getCategories: (params: AllCategoriesFindRequest) => getCategories(params, clientArgs), diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index c558fc258d3a2..608a9ae2ff510 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -53,7 +53,7 @@ const createCasesSubClientMock = (): CasesSubClientMock => { get: jest.fn(), bulkGet: jest.fn(), push: jest.fn(), - update: jest.fn(), + bulkUpdate: jest.fn(), delete: jest.fn(), getTags: jest.fn(), getReporters: jest.fn(), diff --git a/x-pack/plugins/cases/server/connectors/cases/README.md b/x-pack/plugins/cases/server/connectors/cases/README.md new file mode 100644 index 0000000000000..de04640dcf181 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/README.md @@ -0,0 +1,579 @@ +# Summary + + +The case action groups related alerts to cases automatically based on rules and conditions when alerts are detected. Specifically the case action: + +1. Allows users to automatically attach alerts to a case. +2. Allows users to group alerts by a field and attach all alerts of each group to a case. Each group will be attached to its own case. +3. Allows users to define a time window specifying when alerts will be attached to an existing case instead of creating a new one based on time. +4. Allows users to configure if they want to reopen the case if it is closed. + +# Architecture + +## Terminology + +| | | +| :--- | :-------------------------------- | +| GD | Grouping definition | +| RGD | Rule and grouping definition | +| RGDP | Rule and grouping definition pair | + +## Connector adapter + +## High-level flow + +The case action groups all alerts based on the grouping field defined by the user. Then the case action for each group does the following: +1. Check if there is a case that already represents the specific group for the specific rule. +2. If not, it will create a new case and attach the alerts to the new case. If there is a case, then it will do the following checks: + 1. Check if the case has already 1K alerts attached to it. If yes it will log a warning and terminate. + 2. Check if the case is older than the defined time window. If yes it will create a new case as described in Step 1. + 3. Check if the case is closed. If it is, it will check if the case should be reopened. If yes the case action will reopen the case and attach the alerts to it. If not it will create a new case and attach the alerts to the new case. + +If an alert does not belong to a group it will be attached to a case representing the `unknown` value. Also, if no grouping field is configured by the user, all alerts will be attached to the same case. + +```mermaid +flowchart TB + subgraph top [" "] + direction TB + startState --> alerts + alerts --> groupBy + + startState((Start)) + alerts[/Alerts/] + groupBy[Group alerts by field] + end + +subgraph bottom [for each group] + direction LR + caseExists -->|Yes| alertsLimit + caseExists -->|No| createNewCase + alertsLimit -->|Yes| endState + alertsLimit -->|No| caseOld + caseOld -->|Yes| createNewCase + caseOld -->|No| caseClosed + caseClosed -->|Yes| shouldOpenCase + caseClosed -->|No| attachAlertsToCase + shouldOpenCase -->|Yes| openCase + shouldOpenCase -->|No| createNewCase + + createNewCase --> attachAlertsToCase + openCase --> attachAlertsToCase + attachAlertsToCase --> endState + + caseExists{Does the case exists for this group?} + alertsLimit{Are there more than 1K alerts to the case?} + caseOld{Is the case older than the time window?} + caseClosed{Is the case closed?} + shouldOpenCase{Should I open the case?} + + createNewCase[Create new case] + attachAlertsToCase[Attach the alerts to the case] + openCase[Open case] + + endState((End)) +end + +top --> bottom +``` + +## Grouping + +The case action accepts an array of alerts provided by the connector adapter. Duplicate alerts will not be attached to the same case. The case action groups the alerts by the grouping field configured by the user. For example, if the grouping field is `host.name` the case action will group the alerts by the values of the `host.name` field. Users may define more than one grouping field. In this case, the grouping will be done by multiple fields. The grouping is performed in memory by the case action as the number of alerts is expected to be low on average and they are already loaded in memory by the alerting framework. + +```mermaid +flowchart LR + + alert1 --> groupingBy + alert2 --> groupingBy + alert3 --> groupingBy + + subgraph top ["IP: 0.0.0.1"] + alert11[Alert 1] + alert22[Alert 2] + end + + subgraph bottom ["IP: 0.0.0.2"] + alert23[Alert 3] + end + + groupingBy --> top + groupingBy --> bottom + + alert1[Alert 1] + alert2[Alert 2] + alert3[Alert 3] + groupingBy[Grouping by IP] +``` + +```mermaid +flowchart LR + + alert1 --> groupingBy + alert2 --> groupingBy + alert3 --> groupingBy + + subgraph top ["IP: 0.0.0.1 AND host.name: A"] + alert11[Alert 1] + alert22[Alert 2] + end + + subgraph bottom ["IP: 0.0.0.2 AND host.name: B"] + alert23[Alert 3] + end + + groupingBy --> top + groupingBy --> bottom + + alert1[Alert 1] + alert2[Alert 2] + alert3[Alert 3] + groupingBy[Grouping by IP and host name] +``` + +## Case creation + +For each rule and each group produced by the grouping step, a case will be created and the alerts of that group will be attached to the new case. In future executions of the rule, new alerts that belong to the same group will be attached to the same case. If an alert cannot be grouped, because the grouping field does not exist in its data, it will be attached to a case that represents the `unknown` value. + +To support this, the case action constructs a deterministic deduplication ID which will be set as the case ID. The ID can be constructed on each execution of the case action without the need to persist it and can correctly map alerts of the same group to the case that represents that group. A deduplication ID has two main advantages: + +1. The case action can determine, without persisting the case ID, if a case exists for a specific rule and a specific group. +2. If two Kibana nodes or two executions of the same rule try to create the case with the same ID only one node or execution will succeed. See the Race Conditions section for more details. + +The deduplication ID will be constructed as + +`sha256(::::)` + +where + +``: The ID of the rule. Including it in the deduplication ID ensures all cases created are at least rule-specific. +``: The space ID of the rule. Space ID is a required field. +``: The owner of the Case. This will be set to the application from which the rule was created from. Owner is a required field. +``: The grouping field and the grouping value. It can be optional to support attaching all alerts of a rule to the same case. +``: The total number of cases with the same rule ID and same group definition. See Time Window for more details. + +It is not possible for the `` and the `` to be undefined at the same time. If the `` is not defined by the user then the rule `` will be set automatically by the case action. + +Examples of possible deduplication IDs: +`sha256(test_rule_id:default:securitySolution:{"host.ip":"0.0.0.1"}:0)` +`sha256(test_rule_2_id:my_space:observability:{"host.ip":"0.0.0.1","host.name":"A"}:1)` +`sha256(test_rule_2_id:default:securitySolution::0)` +`sha256(:default:host.ip=0.0.0.1:observability:0)` + +The case action sorts deterministically the group definition by the field key to avoid having different IDs for the same group definition. For example, `{"host.ip":"0.0.0.1","host.name":"A"}` and `{"host.name":"A","host.ip":"0.0.0.1"}` will produce the same deduplication ID. + +```mermaid +flowchart LR + + alert1 --> groupingBy + alert2 --> groupingBy + alert3 --> groupingBy + alert4 --> groupingBy + + subgraph top ["IP: 0.0.0.1"] + alert11[Alert 1] + alert22[Alert 2] + end + + subgraph bottom ["IP: 0.0.0.2"] + alert23[Alert 3] + end + + subgraph no_grouping ["IP: unknown"] + alert34[Alert 4] + end + + groupingBy --> top + groupingBy --> bottom + groupingBy --> no_grouping + + top --> caseOne + bottom --> caseTwo + no_grouping --> caseThree + + alert1[Alert 1] + alert2[Alert 2] + alert3[Alert 3] + alert4[Alert 4] + groupingBy[Grouping by IP] + caseOne[Case 1] + caseTwo[Case 2] + caseThree[Case 3] +``` + +## Time window + +Users are able to define a time window. The case action will attach alerts generated within the time window to the same case. For example, if the time window is set to 7 days all the alerts generated within the next 7 days will be assigned to the same case, and on the 8th day, a new case will be created. The time window is defaulted to seven days. + +The new case is a continuation of the previous case. It still represents the case of the specific rule and the specific group (RGD). + +To be able to support time windows we need the following: +1. Unique deduplication ID for cases with the same rule ID and the same grouping definition (RGD). +2. Be able to detect if the time window has elapsed. + +By adding the counter in the deduplication ID we guarantee that the ID will be unique for the same rule ID and group definition. To be able to increase the counter when needed and to detect if the time window has elapsed for the specific RGDP an Oracle [2] is used. The Oracle keeps a map of the current counter and the last date it got updated by the case action for all RGDP. The Oracle satisfies the following properties: + +1. For a valid RGDP, it will return the latest case counter. +2. If two executions of the same rule try to increase the counter at the same time only one execution will succeed. +3. If two Kibana nodes try to increase the counter at the same time only one node will succeed. + +An entry in the map of the Oracle looks like this: + +| Key (saved object ID) | Value (saved object attributes) | +| :----------------------------------------------------------- | :---------------------------------------------------------- | +| `sha256(:::)` | `{ counter, createdAt, updatedAt, cases, rules, grouping }` | + +The `cases`, `rules`, and `grouping` are needed in case we need to provide correlation statistics between cases and rules. The map is persisted in a dedicated saved object called `cases-oracle`. The SavedObject client is used to create and update the records of the map. + +```mermaid +flowchart RL + + caseThree --> caseTwo + caseTwo --> caseOne + + caseOne[Case 1] + caseTwo[Case 2] + caseThree[Case 3] +``` + +```mermaid +flowchart TB + subgraph top ["Rule ID: test-rule. Grouping definition: host.name=A"] + direction BT + + subgraph first ["7 days / counter 1"] + direction BT + re11 --> caseOne + re12 --> caseOne + re13 --> caseOne + + re11[Rule execution] + re12[Rule execution] + re13[Rule execution] + end + + subgraph second ["7 days / counter 2"] + direction BT + re21 --> caseTwo + + re21[Rule execution] + end + + subgraph three ["7 days / counter 3"] + direction BT + re31 --> caseThree + re32 --> caseThree + + re31[Rule execution] + re32[Rule execution] + end + + caseOne[Case 1] + caseTwo[Case 2] + caseThree[Case 3] + end +``` + +## Usage of the Oracle + +The case action deterministically calculates the key of the Oracle mapping based on the rule ID and the GD. Then it executes the following steps: +1. Get the record by key. The record contains the latest counter and the latest date the counter got updated for the specific RGDP. If the record does not exist it will create the record, set the counter to one, and set the `updatedAt` and `createdAt` to the current timestamp. +2. Check if `updatedAt` + `timeWindow` < `now`. + 1. If the expression results to true (i.e. the current time is still within the time window), it will calculate the case ID as described in the Case creation section, and attach the alerts to the existing case. + 2. If the expression results to false (i.e. the current time is not within the time window), it will increase the counter, calculate the case ID as described in the Case creation section using the increased counter, create the case, and attach the alerts to the new case. + +If a version conflict occurs in any of the steps, the execution will be rescheduled to run again. See the Race Conditions section for more details. + +```mermaid +flowchart TB + + startState --> generateMappingKey + generateMappingKey --> getRecord + getRecord --> recordExists + recordExists -->|Yes| timeWindowElapsed + recordExists -->|No| createRecord + createRecord --> endState + timeWindowElapsed -->|Yes| updateCounter + timeWindowElapsed -->|No| endState + updateCounter --> endState + + startState((Start)) + generateMappingKey[Generate oracle mapping key] + getRecord[Get oracle record by key] + createRecord[Create record and set the counter to one] + recordExists{Does the record exist?} + timeWindowElapsed{Does the time window elapses?} + updateCounter[Update counter] + endState((End)) +``` + +## Race conditions + +There are two important operations within the case action: incrementing the counter in the Oracle and the creation of the case. It is possible for these operations to be executed at the same time by two Kibana nodes or by two executions of the same rule at the same time. The first scenario may happen if two rules with the same grouping definition (GD) try to either increase the counter or create the case at the same time. The second scenario may happen if the rule is scheduled to run very often. It is possible for the next run of the rule to happen before the first execution finishes. Although less likely, it may be possible for two different executions of the same rule to try to update the counter or create a new case at the same time especially if the execution time is much bigger than the interval time of the rule. + +### Updating the counter + +The optimistic concurrency control mechanism [1] of Elasticsearch guarantees that changes are applied in the correct order; an older version of a document does not overwrite a newer version. The case action leverages this property to ensure that the counter stored in the mapping of the Oracle is updated correctly in the scenario of race conditions. Specifically, the case action first gets the counter for the current RGDP. Along with the attributes, the version of the saved object is returned. In the case of the update, the case action provides the version and tries to update the counter. If in the meantime the counter got updated the version of the saved object will be different. As the provided version of the saved object is different from the current one, a conflict error will be thrown. + +```mermaid +flowchart RL + subgraph one [Kibana node] + ruleA[Rule] + end + + subgraph two [Kibana node] + ruleB[Rule] + end + + one -->|Get counter|oracle --> |Counter: 1|one -->|Update counter to 2|oracle + two -->|Get counter|oracle --> |Counter: 1|two --x|"Update counter to 2 (failed)"|oracle + + oracle[Oracle] +``` + +```mermaid +flowchart LR + subgraph one [Kibana node] + executionA[Execution A] + executionB[Execution B] + end + + executionA -->|Get counter|oracle --> |Counter: 1|executionA -->|Update counter to 2|oracle + executionB -->|Get counter|oracle --> |Counter: 1|executionB --x|"Update counter to 2 (failed)"|oracle + + oracle[Oracle] +``` + +### Creating the case + +The ID of the case is computed deterministically based on the rule ID, the group definition, and the counter. If for some reason, two executions of the case action compute the same case ID and try to create the case at the same time one of the two executions will succeed and only one case will be created. This is a guarantee provided by Elasticsearch [4]. + +```mermaid +flowchart LR + subgraph one [Kibana node] + ruleA[Rule] + end + + subgraph two [Kibana node] + ruleB[Rule] + end + + one -->|Create case with ID 1|case + two --x|"Create case with ID 1 (failed)"|case + + case[Case 1] +``` + +```mermaid +flowchart LR + subgraph one [Kibana node] + executionA[Execution A] + executionB[Execution B] + end + + executionA -->|Create case with ID 1|case + executionB --x|"Create case with ID 1 (failed)"|case + + case[Case 1] +``` + +### Retries + +To avoid losing alerts in case of conflicts, the failed executions will be retried again. The case action applies a capped exponential back-off mechanism with some randomness for retries [5, 6]. The maximum number of retries is set to ten and the duration between retries is short to avoid timeouts. As the case action is idempotent it is safe to retry the same action multiple times. The retry policy applies to all conflict failures. Specifically: + +| Outcome | Reason | Flow of the retried execution | +| :------------------------------------------------- | :------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | +| Failure due to conflicts when updating the counter | The counter got updated by another execution | No need to increase the counter as it was increased by the previous execution. The new counter will be used. | +| Failure due to conflicts when creating a case | The case got created by another execution | No need to create a new case. Alerts will be attached to the case created by the previous execution. | + +If the case action exhausts all retries then the execution is rescheduled to be executed again at some point in the future by the task manager. This is a feature supported by the alerting framework and will be used by the case action. The maximum number of retries, using the alerting framework, is set to three. + +By having a retry policy on conflicts, the possibility of encountering the same race conditions is very low [5, 6]. In the very unlikely scenario [5, 6] where all retries fail, the alerts will be not attached to any case. + +## RBAC + +Cases are used in the Security Solution, Observability, and Stack. A user having access only to one solution should not be able to create or view cases of another solution. To achieve that, Cases developed its own RBAC. A field called `owner`, in the case SavedObject, indicates the solution to which the case belongs. For example, a case with owner `securitySolution` belongs to Security Solution. A user with no access to Security Solution cannot access cases with owner `securitySolution`. The case action uses the cases’ client to create and update cases and to attach alerts to cases. The case client performs RBAC checks on these operations. + +The case action gets the owner as a configuration parameter. The UI sets the owner based on the application the user is creating the rule from. This means that if a user creates a rule from within the Security Solution the case will be created in Security Solution. Same for the Observability. + +## Circuit breakers & Optimizations + +The total number of cases that can be created or updated and the total number of records in the mapping of the Oracle is associated with the total number of grouping fields and the total number of unique values per grouping field. For example: + +| Grouping fields | Total unique values | Total unique values | +| :------------------------------ | :-------------------------------------------------- | :------------------ | +| host.name | 2 | 2 | +| host.name & dest.ip | 2 for host.name and 3 for dest.ip | 6 | +| host.name & dest.ip & file.hash | 2 for host.name, 3 for dest.ip, and 2 for file.hash | 12 | + +For `n` fields, the total number of cases that can be created is `|S1|*|S2|...|Sn|` where `Sn` is the set containing all unique values of the `n` grouping field [8]. For example, if there are 5 grouping fields with 10 unique values on each one then the total number of cases that will be created are `10^5 = 100.000`, a very high number. To avoid creating too many cases the number of total fields a user can define will be capped to one. With this limit the total number of cases that can be created is `|S1|`. Still, it can lead to a high number of cases if the unique values per grouping field are a lot. To mitigate this the case action a) uses a bulk create case API to create multiple cases at the same time and b) puts a limit on the number of total cases that can be created on an execution. The limit is set to 10 cases and can be configured by users. If more than 10 cases need to be created then the case action will create one case and attach all the alerts to that case. As the size of the mapping of the Oracle is related to the number of grouping fields and grouping values the same mitigations will apply also to it. + +## Missing data + +It is possible for users to delete an auto-created case or to import partial data. The case action should handle these scenarios gracefully. The following table and the following diagrams show all possible scenarios when either the case is auto-created or the counter is missing or invalid. + +| Counter | Case | Reason | Resolution action | +| :------ | :-------- | :------------------------------------------ | :------------------------------------------------------------------ | +| Invalid | Found | Oracle is not imported or deleted | Set the counter to zero. Start over. Attach the alerts to the case. | +| Valid | Not found | The case is deleted or not imported | Create the case using the valid counter. | +| Valid | Found | System is functioning as expected | Continue the flow as expected. | +| Invalid | Not found | Oracle and case are not imported or deleted | Set the counter to zero and start over. | + +```mermaid +flowchart BT + + caseAction<-->|Get counter|oracle + caseAction-->|"Attach alerts \nCounter: 1"|caseOne + caseAction-->|"Create case \nCounter: 2"|caseTwo + caseAction-->|"Attach alerts \nCounter: 3"|caseThree + + caseOne["Case 1 \n(exists)"] + caseTwo["Case 2 \n(does not exist)"] + caseThree["Case 3 \n(exists)"] + caseAction[Case action] + oracle["Oracle \nCounter: 1"] +``` + +```mermaid +flowchart BT + + caseAction<-->|Get counter|oracle + caseAction-->|"Create case \nCounter: 1"|caseOne + caseAction-->|"Create case \nCounter: 2"|caseTwo + caseAction-->|"Create case \nCounter: 3"|caseThree + + caseOne["Case 1 \n(does not exist)"] + caseTwo["Case 2 \n(does not exist)"] + caseThree["Case 3 \n(does not exist)"] + caseAction[Case action] + oracle["Oracle \n(does not exist)"] +``` + +```mermaid +flowchart BT + + caseAction<-->|Get counter|oracle + caseAction-->|"Attach alerts \nCounter: 1"|caseOne + caseAction-->|"Create case \nCounter: 2"|caseTwo + caseAction-->|"Attach alerts \nCounter: 3"|caseThree + + caseOne["Case 1 \n(exist)"] + caseTwo["Case 2 \n(does not exist)"] + caseThree["Case 3 \n(exist)"] + caseAction[Case action] + oracle["Oracle \n(does not exist)"] +``` + +## Case action as a finite state machine + +The case action can be modeled as a finite state machine. The following state machine shows all the possible states and transitions of the case action. + +```mermaid +stateDiagram-v2 + %% oracle ID: getting counter + [*]-->oracleIdGen: Generate Oracle ID + oracleIdGen-->gettingCounter: Get counter + gettingCounter-->counterX: Success + gettingCounter-->conflictError: Confict Error + gettingCounter-->counterNoFound: No found + counterNoFound-->setCounterToOne: Set counter to 1 + setCounterToOne-->counterIsOne: Success + setCounterToOne-->conflictError: Confict Error + + %% handle time window + counterX-->calculateTimeWindow: Calculate time window + counterIsOne-->calculateTimeWindow: Calculate time window + calculateTimeWindow-->timeWindowElapsed: Time window elapsed + calculateTimeWindow-->timeWindowNotElapsed: Time window not elapsed + timeWindowElapsed-->incresingCounter: Increase counter by one + incresingCounter-->counterIncreased: Success + incresingCounter-->conflictError: Confict Error + + %% case ID + counterIncreased-->caseIdGen: Generate case ID + timeWindowNotElapsed-->caseIdGen: Generate case ID + caseIdGen-->gettingCase: Get case + gettingCase-->caseFound: Success + gettingCase-->caseNoFound: No found + gettingCase-->conflictError: Confict Error + caseNoFound-->createCase: Create new case + createCase-->caseCreated: Success + createCase-->conflictError: Confict Error + + %% closed cases + caseFound-->getCaseStatus: Get case status + getCaseStatus-->closedCase: Case closed + getCaseStatus-->notClosedCase: Case not closed + closedCase-->reopenCase: Reopen case + reopenCase-->caseReopened: Success + reopenCase-->conflictError: Confict Error + closedCase-->createCase: Create new case + + %% alerts + caseReopened-->calculateAlertsOnCase: Calculate total alerts on case + notClosedCase-->calculateAlertsOnCase: Calculate total alerts on case + calculateAlertsOnCase-->caseAlertsOnLimit: Alerts on case ≥ 1K + calculateAlertsOnCase-->caseAlertsNotLimit: Alerts on case < 1K + caseAlertsNotLimit-->attachAlertsToCase: Attach alerts to case + caseCreated-->attachAlertsToCase: Attach alerts to case + attachAlertsToCase-->alertsAttachedToCase: Success + attachAlertsToCase-->conflictError: Confict Error + + %% errors and end states + conflictError-->[*] + alertsAttachedToCase -->[*] + caseAlertsOnLimit -->[*] + + %% counters + oracleIdGen: Oracle ID generated + gettingCounter: Getting counter + counterX: Counter is X + counterNoFound: Counter no found + setCounterToOne: Setting counter to 1 + counterIsOne: Counter is one + incresingCounter: Increasing counter by one + counterIncreased: Counter increased by one + + %% cases + caseIdGen: Case ID generated + gettingCase: Getting case + caseFound: Case found + caseNoFound: Case no found + createCase: Creating case + caseCreated: Case created + getCaseStatus: Getting case status + closedCase: Case is closed + notClosedCase: Case is not closed + reopenCase: Reopening case + caseReopened: Case reopened + + %% time window + calculateTimeWindow: Calculating time window + timeWindowElapsed: Time window elapsed + timeWindowNotElapsed: Time window not elapsed + + %% alerts + calculateAlertsOnCase: Calculating total alerts on case + caseAlertsOnLimit: Alerts on case ≥ 1K + caseAlertsNotLimit: Alerts on case < 1K + attachAlertsToCase: Attaching alerts to case + alertsAttachedToCase: Alerts attached to case + + %% errors + conflictError: Confict Error +``` + +## Error handling + +If 409 (Conflict), 429 (Too Many Requests), or 503 (ES Unavailable) occurs the case action will retry the execution as described on the Retries section. For all other errors, the case action will be rescheduled by the task manager. The retry mechanism of the case action and the alerting framework will eliminate most of the transient errors [5, 6]. If after three attempts the execution still fails, the case action will not be rescheduled again, the error will be logged to the event log and no cases will be created. + +## References + +[1] Elastic. 2023. “Optimistic Concurrency Control”. [URL](https://www.elastic.co/guide/en/elasticsearch/guide/master/optimistic-concurrency-control.html). +[2] Wikipedia. 2023. “Oracle machine”. [URL](https://en.wikipedia.org/wiki/Oracle_machine). +[3] Elastic. 2023. “Update Cases API”. [URL](https://www.elastic.co/guide/en/kibana/master/cases-api-update.html). +[4] Elastic. 2023. “Index API”. [URL](https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html#operation-type). +[5] Amazon AWS. 2023. “Exponential Backoff And Jitter”. [URL](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/). +[6] Amazon AWS. 2023. “Failures Happen”. [URL](https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/?did=ba_card&trk=ba_card). +[7] Kibana telemetry. 2023. “Alerting rule metrics”. +[8] Wikipedia. 2023. “Rule of product”. [URL](https://en.wikipedia.org/wiki/Rule_of_product). +[9] Github 2023. “json-stable-stringify”. [URL](https://github.com/ljharb/json-stable-stringify). \ No newline at end of file diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_connector.test.ts b/x-pack/plugins/cases/server/connectors/cases/cases_connector.test.ts new file mode 100644 index 0000000000000..f7f6c0d510f2f --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_connector.test.ts @@ -0,0 +1,288 @@ +/* + * 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 { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { CasesConnector } from './cases_connector'; +import { CasesConnectorExecutor } from './cases_connector_executor'; +import { CASES_CONNECTOR_ID } from '../../../common/constants'; +import { CasesOracleService } from './cases_oracle_service'; +import { CasesService } from './cases_service'; +import { CasesConnectorError } from './cases_connector_error'; +import { CaseError } from '../../common/error'; +import { fullJitterBackoffFactory } from './full_jitter_backoff'; +import { CoreKibanaRequest } from '@kbn/core/server'; + +jest.mock('./cases_connector_executor'); +jest.mock('./full_jitter_backoff'); + +const CasesConnectorExecutorMock = CasesConnectorExecutor as jest.Mock; +const fullJitterBackoffFactoryMock = fullJitterBackoffFactory as jest.Mock; + +describe('CasesConnector', () => { + const services = actionsMock.createServices(); + const logger = loggingSystemMock.createLogger(); + const kibanaRequest = CoreKibanaRequest.from({ path: '/', headers: {} }); + + const groupingBy = ['host.name', 'dest.ip']; + const rule = { + id: 'rule-test-id', + name: 'Test rule', + tags: ['rule', 'test'], + ruleUrl: 'https://example.com/rules/rule-test-id', + }; + + const owner = 'cases'; + const timeWindow = '7d'; + const reopenClosedCases = false; + const maximumCasesToOpen = 5; + + const mockExecute = jest.fn(); + const getCasesClient = jest.fn().mockResolvedValue({ foo: 'bar' }); + const getSpaceId = jest.fn().mockReturnValue('default'); + const getUnsecuredSavedObjectsClient = jest.fn(); + // 1ms delay before retrying + const nextBackOff = jest.fn().mockReturnValue(1); + + const backOffFactory = { + create: () => ({ nextBackOff }), + }; + + const casesParams = { getCasesClient, getSpaceId, getUnsecuredSavedObjectsClient }; + const connectorParams = { + configurationUtilities: actionsConfigMock.create(), + config: {}, + secrets: {}, + connector: { id: '1', type: CASES_CONNECTOR_ID }, + logger, + services, + request: kibanaRequest, + }; + + let connector: CasesConnector; + + beforeEach(() => { + jest.clearAllMocks(); + mockExecute.mockResolvedValue({}); + + CasesConnectorExecutorMock.mockImplementation(() => { + return { + execute: mockExecute, + }; + }); + + fullJitterBackoffFactoryMock.mockReturnValue(backOffFactory); + + connector = new CasesConnector({ + casesParams, + connectorParams, + }); + }); + + it('creates the CasesConnectorExecutor correctly', async () => { + await connector.run({ + alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }); + + expect(CasesConnectorExecutorMock).toBeCalledWith({ + logger, + casesClient: { foo: 'bar' }, + casesOracleService: expect.any(CasesOracleService), + casesService: expect.any(CasesService), + spaceId: 'default', + }); + }); + + it('executes the CasesConnectorExecutor correctly', async () => { + await connector.run({ + alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }); + + expect(mockExecute).toBeCalledWith({ + alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }); + }); + + it('creates the cases client correctly', async () => { + await connector.run({ + alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }); + + expect(getCasesClient).toBeCalled(); + }); + + it('throws the same error if the executor throws a CasesConnectorError error', async () => { + mockExecute.mockRejectedValue(new CasesConnectorError('Bad request', 400)); + + await expect(() => + connector.run({ + alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Bad request"`); + + expect(logger.error.mock.calls[0][0]).toBe( + '[CasesConnector][run] Execution of case connector failed. Message: Bad request. Status code: 400' + ); + }); + + it('throws a CasesConnectorError when the executor throws an CaseError error', async () => { + mockExecute.mockRejectedValue(new CaseError('Forbidden')); + + await expect(() => + connector.run({ + alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Forbidden"`); + + expect(logger.error.mock.calls[0][0]).toBe( + '[CasesConnector][run] Execution of case connector failed. Message: Forbidden. Status code: 500' + ); + }); + + it('throws a CasesConnectorError when the executor throws an Error', async () => { + mockExecute.mockRejectedValue(new Error('Server error')); + + await expect(() => + connector.run({ + alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Server error"`); + + expect(logger.error.mock.calls[0][0]).toBe( + '[CasesConnector][run] Execution of case connector failed. Message: Server error. Status code: 500' + ); + }); + + it('throws a CasesConnectorError when the executor throws a Boom error', async () => { + mockExecute.mockRejectedValue( + new Boom.Boom('Server error', { statusCode: 403, message: 'my error message' }) + ); + + await expect(() => + connector.run({ + alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Forbidden: Server error"`); + + expect(logger.error.mock.calls[0][0]).toBe( + '[CasesConnector][run] Execution of case connector failed. Message: Forbidden: Server error. Status code: 403' + ); + }); + + it('retries correctly', async () => { + mockExecute + .mockRejectedValueOnce(new CasesConnectorError('Conflict error', 409)) + .mockRejectedValueOnce(new CasesConnectorError('ES Unavailable', 503)) + .mockResolvedValue({}); + + await connector.run({ + alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }); + + expect(nextBackOff).toBeCalledTimes(2); + expect(mockExecute).toBeCalledTimes(3); + }); + + it('throws if the kibana request is not defined', async () => { + connector = new CasesConnector({ + casesParams, + connectorParams: { ...connectorParams, request: undefined }, + }); + + await expect(() => + connector.run({ + alerts: [{ _id: 'alert-id-0', _index: 'alert-index-0' }], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Kibana request is not defined"`); + + expect(logger.error.mock.calls[0][0]).toBe( + '[CasesConnector][run] Execution of case connector failed. Message: Kibana request is not defined. Status code: 400' + ); + + expect(nextBackOff).toBeCalledTimes(0); + expect(mockExecute).toBeCalledTimes(0); + }); + + it('does not execute with no alerts', async () => { + await connector.run({ + alerts: [], + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen, + }); + + expect(getCasesClient).not.toBeCalled(); + expect(CasesConnectorExecutorMock).not.toBeCalled(); + expect(mockExecute).not.toBeCalled(); + expect(nextBackOff).not.toBeCalled(); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_connector.ts b/x-pack/plugins/cases/server/connectors/cases/cases_connector.ts new file mode 100644 index 0000000000000..89c133f828b77 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_connector.ts @@ -0,0 +1,210 @@ +/* + * 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 type { ServiceParams } from '@kbn/actions-plugin/server'; +import { SubActionConnector } from '@kbn/actions-plugin/server'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { SavedObjectsClientContract } from '@kbn/core/server'; +import { SAVED_OBJECT_TYPES } from '../../../common'; +import type { CasesConnectorConfig, CasesConnectorRunParams, CasesConnectorSecrets } from './types'; +import { CasesConnectorRunParamsSchema } from './schema'; +import { CasesOracleService } from './cases_oracle_service'; +import { CasesService } from './cases_service'; +import type { CasesClient } from '../../client'; +import { + CasesConnectorError, + isCasesClientError, + isCasesConnectorError, +} from './cases_connector_error'; +import { CasesConnectorExecutor } from './cases_connector_executor'; +import { CaseConnectorRetryService } from './retry_service'; +import { fullJitterBackoffFactory } from './full_jitter_backoff'; +import { CASE_RULES_SAVED_OBJECT, CASES_CONNECTOR_SUB_ACTION } from '../../../common/constants'; + +interface CasesConnectorParams { + connectorParams: ServiceParams; + casesParams: { + getCasesClient: (request: KibanaRequest) => Promise; + getSpaceId: (request?: KibanaRequest) => string; + getUnsecuredSavedObjectsClient: ( + request: KibanaRequest, + savedObjectTypes: string[] + ) => Promise; + }; +} + +export class CasesConnector extends SubActionConnector< + CasesConnectorConfig, + CasesConnectorSecrets +> { + private readonly casesService: CasesService; + private readonly retryService: CaseConnectorRetryService; + private readonly casesParams: CasesConnectorParams['casesParams']; + + constructor({ connectorParams, casesParams }: CasesConnectorParams) { + super(connectorParams); + + this.casesService = new CasesService(); + + /** + * We should wait at least 5ms before retrying and no more that 2sec + */ + const backOffFactory = fullJitterBackoffFactory({ baseDelay: 5, maxBackoffTime: 2000 }); + this.retryService = new CaseConnectorRetryService(this.logger, backOffFactory); + + this.casesParams = casesParams; + + this.registerSubActions(); + } + + private registerSubActions() { + this.registerSubAction({ + name: CASES_CONNECTOR_SUB_ACTION.RUN, + method: 'run', + schema: CasesConnectorRunParamsSchema, + }); + } + + /** + * Method is not needed for the Case Connector. + * The function throws an error as a reminder to + * implement it if we need it in the future. + */ + protected getResponseErrorMessage(): string { + throw new Error('Method not implemented.'); + } + + public async run(params: CasesConnectorRunParams) { + if (!this.kibanaRequest) { + const error = new CasesConnectorError('Kibana request is not defined', 400); + this.handleError(error); + } + + if (params.alerts.length === 0) { + this.logDebugCurrentState( + 'start', + '[CasesConnector][_run] No alerts. Skipping execution.', + params + ); + + return; + } + + await this.retryService.retryWithBackoff(() => this._run(params)); + } + + private async _run(params: CasesConnectorRunParams) { + try { + /** + * The case connector will throw an error if the Kibana request + * is not define before executing the _run method + */ + const kibanaRequest = this.kibanaRequest as KibanaRequest; + const casesClient = await this.casesParams.getCasesClient(kibanaRequest); + const savedObjectsClient = await this.casesParams.getUnsecuredSavedObjectsClient( + kibanaRequest, + [...SAVED_OBJECT_TYPES, CASE_RULES_SAVED_OBJECT] + ); + + const spaceId = this.casesParams.getSpaceId(kibanaRequest); + + const casesOracleService = new CasesOracleService({ + logger: this.logger, + savedObjectsClient, + }); + + const connectorExecutor = new CasesConnectorExecutor({ + logger: this.logger, + casesOracleService, + casesService: this.casesService, + casesClient, + spaceId, + }); + + this.logDebugCurrentState('start', '[CasesConnector][_run] Executing case connector', params); + + await connectorExecutor.execute(params); + + this.logDebugCurrentState( + 'success', + '[CasesConnector][_run] Execution of case connector succeeded', + params + ); + } catch (error) { + this.handleError(error); + } finally { + this.logDebugCurrentState( + 'end', + '[CasesConnector][_run] Execution of case connector ended', + params + ); + } + } + + private handleError(error: Error) { + if (isCasesConnectorError(error)) { + this.logError(error); + throw error; + } + + if (isCasesClientError(error)) { + const caseConnectorError = new CasesConnectorError( + error.message, + error.boomify().output.statusCode + ); + + this.logError(caseConnectorError); + throw caseConnectorError; + } + + if (Boom.isBoom(error)) { + const caseConnectorError = new CasesConnectorError( + `${error.output.payload.error}: ${error.output.payload.message}`, + error.output.statusCode + ); + + this.logError(caseConnectorError); + + throw caseConnectorError; + } + + const caseConnectorError = new CasesConnectorError(error.message, 500); + this.logError(caseConnectorError); + + throw caseConnectorError; + } + + private logDebugCurrentState(state: string, message: string, params: CasesConnectorRunParams) { + const alertIds = params.alerts.map(({ _id }) => _id); + + this.logger.debug(`[CasesConnector][_run] ${message}`, { + labels: { + ruleId: params.rule.id, + groupingBy: params.groupingBy, + totalAlerts: params.alerts.length, + timeWindow: params.timeWindow, + reopenClosedCases: params.reopenClosedCases, + owner: params.owner, + }, + tags: [`cases-connector:${state}`, params.rule.id, ...alertIds], + }); + } + + private logError(error: CasesConnectorError) { + this.logger.error( + `[CasesConnector][run] Execution of case connector failed. Message: ${error.message}. Status code: ${error.statusCode}`, + { + error: { + stack_trace: error.stack, + code: error.statusCode.toString(), + type: 'CasesConnectorError', + }, + } + ); + } +} diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_connector_error.ts b/x-pack/plugins/cases/server/connectors/cases/cases_connector_error.ts new file mode 100644 index 0000000000000..67383c6fd78c0 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_connector_error.ts @@ -0,0 +1,24 @@ +/* + * 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 { CaseError } from '../../common/error'; + +export class CasesConnectorError extends Error { + public readonly statusCode: number; + + constructor(message: string, statusCode: number) { + super(message); + + this.statusCode = statusCode; + } +} + +export const isCasesConnectorError = (error: unknown): error is CasesConnectorError => + error instanceof CasesConnectorError; + +export const isCasesClientError = (error: unknown): error is CaseError => + error instanceof CaseError; diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_connector_executor.test.ts b/x-pack/plugins/cases/server/connectors/cases/cases_connector_executor.test.ts new file mode 100644 index 0000000000000..e09cf40487dc6 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_connector_executor.test.ts @@ -0,0 +1,2737 @@ +/* + * 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 dateMath from '@kbn/datemath'; +import moment from 'moment'; +import { CasesConnectorExecutor } from './cases_connector_executor'; +import { + CASE_RULES_SAVED_OBJECT, + MAX_ALERTS_PER_CASE, + MAX_LENGTH_PER_TAG, + MAX_TAGS_PER_CASE, + MAX_TITLE_LENGTH, +} from '../../../common/constants'; +import { CasesOracleService } from './cases_oracle_service'; +import { CasesService } from './cases_service'; +import { createCasesClientMock } from '../../client/mocks'; +import { CaseStatuses } from '@kbn/cases-components'; +import { CaseError } from '../../common/error'; +import { + alerts, + cases, + createdOracleRecord, + groupedAlertsWithOracleKey, + groupingBy, + oracleRecords, + rule, + owner, + timeWindow, + reopenClosedCases, + updatedCounterOracleRecord, + alertsNested, + alertsWithNoGrouping, +} from './index.mock'; +import { + expectCasesToHaveTheCorrectAlertsAttachedWithGrouping, + expectCasesToHaveTheCorrectAlertsAttachedWithGroupingAndIncreasedCounter, +} from './test_helpers'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import type { Logger } from '@kbn/core/server'; +import type { CasesConnectorRunParams } from './types'; +import { INITIAL_ORACLE_RECORD_COUNTER, MAX_OPEN_CASES } from './constants'; +import { CustomFieldTypes } from '../../../common/types/domain'; + +jest.mock('./cases_oracle_service'); +jest.mock('./cases_service'); +jest.mock('@kbn/datemath'); + +const CasesOracleServiceMock = CasesOracleService as jest.Mock; +const CasesServiceMock = CasesService as jest.Mock; +const dateMathMock = dateMath as jest.Mocked; + +describe('CasesConnectorExecutor', () => { + const mockGetRecordId = jest.fn(); + const mockBulkGetRecords = jest.fn(); + const mockBulkCreateRecords = jest.fn(); + const mockBulkUpdateRecord = jest.fn(); + const mockGetCaseId = jest.fn(); + + const getCasesClient = jest.fn(); + const casesClientMock = createCasesClientMock(); + const mockLogger = loggingSystemMock.create().get() as jest.Mocked; + + let connectorExecutor: CasesConnectorExecutor; + let oracleIdCounter = 0; + let caseIdCounter = 0; + + const resetCounters = () => { + oracleIdCounter = 0; + caseIdCounter = 0; + }; + + const params: CasesConnectorRunParams = { + alerts, + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases, + maximumCasesToOpen: 5, + }; + + beforeEach(() => { + jest.clearAllMocks(); + resetCounters(); + + // @ts-expect-error: other properties are not required + CasesOracleServiceMock.mockImplementation(() => { + return { + getRecordId: mockGetRecordId.mockImplementation( + () => `so-oracle-record-${oracleIdCounter++}` + ), + bulkGetRecords: mockBulkGetRecords.mockResolvedValue(oracleRecords), + bulkCreateRecord: mockBulkCreateRecords.mockResolvedValue([createdOracleRecord]), + bulkUpdateRecord: mockBulkUpdateRecord.mockResolvedValue([]), + }; + }); + + // @ts-expect-error: other properties are not required + CasesServiceMock.mockImplementation(() => { + return { + getCaseId: mockGetCaseId.mockImplementation(() => `mock-id-${++caseIdCounter}`), + }; + }); + + casesClientMock.cases.bulkGet.mockResolvedValue({ cases, errors: [] }); + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [] }); + casesClientMock.cases.bulkUpdate.mockResolvedValue([]); + casesClientMock.attachments.bulkCreate.mockResolvedValue(cases[0]); + casesClientMock.configure.get = jest.fn().mockResolvedValue([]); + + getCasesClient.mockReturnValue(casesClientMock); + + connectorExecutor = new CasesConnectorExecutor({ + logger: mockLogger, + casesOracleService: new CasesOracleServiceMock(), + casesService: new CasesServiceMock(), + casesClient: casesClientMock, + spaceId: 'default', + }); + + dateMathMock.parse.mockImplementation(() => moment('2023-10-09T10:23:42.769Z')); + }); + + describe('With grouping', () => { + describe('run', () => { + describe('Initial state', () => { + beforeEach(() => { + mockBulkGetRecords.mockResolvedValue([ + { + id: groupedAlertsWithOracleKey[0].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'Not found', + statusCode: 404, + error: 'Not found', + }, + { + id: groupedAlertsWithOracleKey[1].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'Not found', + statusCode: 404, + error: 'Not found', + }, + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'Not found', + statusCode: 404, + error: 'Not found', + }, + ]); + + mockBulkCreateRecords.mockResolvedValue([ + oracleRecords[0], + oracleRecords[1], + createdOracleRecord, + ]); + + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-1', + }, + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-2', + }, + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-3', + }, + ], + }); + + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases }); + }); + + it('attach the alerts correctly when the rule runs for the first time', async () => { + await connectorExecutor.execute(params); + + expect(mockBulkGetRecords).toHaveBeenCalledTimes(1); + expect(mockBulkGetRecords).toHaveBeenCalledWith([ + groupedAlertsWithOracleKey[0].oracleKey, + groupedAlertsWithOracleKey[1].oracleKey, + groupedAlertsWithOracleKey[2].oracleKey, + ]); + + expect(mockBulkCreateRecords).toHaveBeenCalledTimes(1); + expect(mockBulkCreateRecords).toHaveBeenCalledWith([ + { + recordId: groupedAlertsWithOracleKey[0].oracleKey, + payload: { + grouping: groupedAlertsWithOracleKey[0].grouping, + rules: [ + { + id: 'rule-test-id', + }, + ], + }, + }, + { + recordId: groupedAlertsWithOracleKey[1].oracleKey, + payload: { + grouping: groupedAlertsWithOracleKey[1].grouping, + rules: [ + { + id: 'rule-test-id', + }, + ], + }, + }, + { + recordId: groupedAlertsWithOracleKey[2].oracleKey, + payload: { + grouping: groupedAlertsWithOracleKey[2].grouping, + rules: [ + { + id: 'rule-test-id', + }, + ], + }, + }, + ]); + + expect(casesClientMock.cases.bulkGet).toHaveBeenCalledTimes(1); + expect(casesClientMock.cases.bulkGet).toHaveBeenCalledWith({ + ids: ['mock-id-1', 'mock-id-2', 'mock-id-3'], + }); + + expect(casesClientMock.cases.bulkCreate).toHaveBeenCalledTimes(1); + expect(casesClientMock.cases.bulkCreate).toHaveBeenCalledWith({ + cases: [ + { + id: 'mock-id-1', + title: 'Test rule (Auto-created)', + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `A` and `dest.ip` equals `0.0.0.1`', + owner: 'cases', + settings: { + syncAlerts: false, + }, + tags: [ + 'auto-generated', + 'rule:rule-test-id', + 'host.name:A', + 'dest.ip:0.0.0.1', + ...rule.tags, + ], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + }, + { + id: 'mock-id-2', + title: 'Test rule (Auto-created)', + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `B` and `dest.ip` equals `0.0.0.1`', + owner: 'cases', + settings: { + syncAlerts: false, + }, + tags: [ + 'auto-generated', + 'rule:rule-test-id', + 'host.name:B', + 'dest.ip:0.0.0.1', + ...rule.tags, + ], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + }, + { + id: 'mock-id-3', + title: 'Test rule (Auto-created)', + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `B` and `dest.ip` equals `0.0.0.3`', + owner: 'cases', + settings: { + syncAlerts: false, + }, + tags: [ + 'auto-generated', + 'rule:rule-test-id', + 'host.name:B', + 'dest.ip:0.0.0.3', + ...rule.tags, + ], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + }, + ], + }); + + expectCasesToHaveTheCorrectAlertsAttachedWithGrouping(casesClientMock); + }); + }); + + describe('Oracle records', () => { + it('generates the oracle keys correctly with grouping by one field', async () => { + await connectorExecutor.execute({ + ...params, + groupingBy: ['host.name'], + }); + + expect(mockGetRecordId).toHaveBeenCalledTimes(2); + + expect(mockGetRecordId).nthCalledWith(1, { + ruleId: rule.id, + grouping: { 'host.name': 'A' }, + owner, + spaceId: 'default', + }); + + expect(mockGetRecordId).nthCalledWith(2, { + ruleId: rule.id, + grouping: { 'host.name': 'B' }, + owner, + spaceId: 'default', + }); + }); + + it('generates the oracle keys correct with grouping by multiple fields', async () => { + await connectorExecutor.execute(params); + + expect(mockGetRecordId).toHaveBeenCalledTimes(3); + + for (const [index, { grouping }] of groupedAlertsWithOracleKey.entries()) { + expect(mockGetRecordId).nthCalledWith(index + 1, { + ruleId: rule.id, + grouping, + owner, + spaceId: 'default', + }); + } + }); + + it('gets the oracle records correctly', async () => { + await connectorExecutor.execute(params); + + expect(mockBulkGetRecords).toHaveBeenCalledWith([ + groupedAlertsWithOracleKey[0].oracleKey, + groupedAlertsWithOracleKey[1].oracleKey, + groupedAlertsWithOracleKey[2].oracleKey, + ]); + }); + + it('created the non found oracle records correctly', async () => { + await connectorExecutor.execute(params); + + expect(mockBulkCreateRecords).toHaveBeenCalledWith([ + { + recordId: groupedAlertsWithOracleKey[2].oracleKey, + payload: { + grouping: groupedAlertsWithOracleKey[2].grouping, + rules: [ + { + id: 'rule-test-id', + }, + ], + }, + }, + ]); + }); + + it('does not create oracle records if there are no 404 errors', async () => { + mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); + + await connectorExecutor.execute(params); + + expect(mockBulkCreateRecords).not.toHaveBeenCalled(); + }); + + it('run correctly with all records: valid, counter increased, counter did not increased, created', async () => { + dateMathMock.parse.mockImplementation(() => moment('2023-10-11T10:23:42.769Z')); + mockBulkCreateRecords.mockResolvedValue([createdOracleRecord]); + + mockBulkUpdateRecord.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); + + await connectorExecutor.execute(params); + + // 1. Get all records + expect(mockBulkGetRecords).toHaveBeenCalledWith([ + groupedAlertsWithOracleKey[0].oracleKey, + groupedAlertsWithOracleKey[1].oracleKey, + groupedAlertsWithOracleKey[2].oracleKey, + ]); + + // 2. Create the non found records + expect(mockBulkCreateRecords).toHaveBeenCalledWith([ + { + recordId: groupedAlertsWithOracleKey[2].oracleKey, + payload: { + grouping: groupedAlertsWithOracleKey[2].grouping, + rules: [ + { + id: 'rule-test-id', + }, + ], + }, + }, + ]); + + // 3. Update the counter for the records where the time window has passed + expect(mockBulkUpdateRecord).toHaveBeenCalledWith([ + { payload: { counter: 2 }, recordId: 'so-oracle-record-0', version: 'so-version-0' }, + ]); + }); + }); + + describe('Time window', () => { + it('does not increase the counter if the time window has not passed', async () => { + mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); + await connectorExecutor.execute(params); + + expect(mockBulkUpdateRecord).not.toHaveBeenCalled(); + }); + + it('updates the counter correctly if the time window has passed', async () => { + dateMathMock.parse.mockImplementation(() => moment('2023-11-10T10:23:42.769Z')); + await connectorExecutor.execute(params); + + expect(mockBulkUpdateRecord).toHaveBeenCalledWith([ + { payload: { counter: 2 }, recordId: 'so-oracle-record-0', version: 'so-version-0' }, + { payload: { counter: 2 }, recordId: 'so-oracle-record-1', version: 'so-version-1' }, + ]); + }); + }); + + describe('Cases', () => { + it('generates the case ids correctly', async () => { + await connectorExecutor.execute(params); + + expect(mockGetCaseId).toHaveBeenCalledTimes(3); + + for (const [index, { grouping }] of groupedAlertsWithOracleKey.entries()) { + expect(mockGetCaseId).nthCalledWith(index + 1, { + ruleId: rule.id, + grouping, + owner, + spaceId: 'default', + counter: 1, + }); + } + }); + + it('generates the case ids correctly when the time window has passed', async () => { + dateMathMock.parse.mockImplementation(() => moment('2023-10-11T10:23:42.769Z')); + + mockBulkUpdateRecord.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); + + await connectorExecutor.execute(params); + + expect(mockGetCaseId).toBeCalledTimes(3); + + /** + * Oracle record index: 0 + * Should update the counter + */ + expect(mockGetCaseId).nthCalledWith(1, { + counter: 2, + grouping: { 'dest.ip': '0.0.0.1', 'host.name': 'A' }, + owner: 'cases', + ruleId: 'rule-test-id', + spaceId: 'default', + }); + + /** + * Oracle record index: 1 + * Should not update the counter + */ + expect(mockGetCaseId).nthCalledWith(2, { + counter: 1, + grouping: { 'dest.ip': '0.0.0.1', 'host.name': 'B' }, + owner: 'cases', + ruleId: 'rule-test-id', + spaceId: 'default', + }); + + /** + * Oracle record index: 3 + * Not found. Created. + */ + expect(mockGetCaseId).nthCalledWith(3, { + counter: 1, + grouping: { 'dest.ip': '0.0.0.3', 'host.name': 'B' }, + owner: 'cases', + ruleId: 'rule-test-id', + spaceId: 'default', + }); + }); + + it('gets the cases correctly', async () => { + await connectorExecutor.execute(params); + + expect(casesClientMock.cases.bulkGet).toHaveBeenCalledWith({ + ids: ['mock-id-1', 'mock-id-2', 'mock-id-3'], + }); + }); + + it('creates non existing cases correctly', async () => { + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [cases[2]] }); + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [cases[0], cases[1]], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-3', + }, + ], + }); + + await connectorExecutor.execute(params); + + expect(casesClientMock.cases.bulkCreate).toHaveBeenCalledWith({ + cases: [ + { + id: 'mock-id-3', + title: 'Test rule (Auto-created)', + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `B` and `dest.ip` equals `0.0.0.3`', + owner: 'cases', + settings: { + syncAlerts: false, + }, + tags: [ + 'auto-generated', + 'rule:rule-test-id', + 'host.name:B', + 'dest.ip:0.0.0.3', + ...rule.tags, + ], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + }, + ], + }); + }); + + it('does not add the rule URL to the description if the ruleUrl is null', async () => { + mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [cases[0]] }); + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-1', + }, + ], + }); + + await connectorExecutor.execute({ + ...params, + rule: { ...params.rule, ruleUrl: null }, + }); + + const description = + casesClientMock.cases.bulkCreate.mock.calls[0][0].cases[0].description; + + expect(description).toBe( + 'This case is auto-created by Test rule. \n\n Grouping: `host.name` equals `A` and `dest.ip` equals `0.0.0.1`' + ); + }); + + it('converts grouping values in the description correctly', async () => { + mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [cases[0]] }); + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-1', + }, + ], + }); + + await connectorExecutor.execute({ + ...params, + alerts: [ + { + _id: 'test-id', + _index: 'test-index', + foo: ['bar', 1, true, {}], + bar: { foo: 'test' }, + baz: 'my value', + }, + ], + groupingBy: ['foo', 'bar', 'baz'], + }); + + const description = + casesClientMock.cases.bulkCreate.mock.calls[0][0].cases[0].description; + + expect(description).toBe( + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `foo` equals `["bar",1,true,{}]` and `bar.foo` equals `test` and `baz` equals `my value`' + ); + }); + + it(`adds the counter correctly if it is bigger than ${INITIAL_ORACLE_RECORD_COUNTER}`, async () => { + mockBulkGetRecords.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [cases[0]] }); + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-1', + }, + ], + }); + + await connectorExecutor.execute({ ...params, groupingBy: [] }); + const title = casesClientMock.cases.bulkCreate.mock.calls[0][0].cases[0].title; + + expect(title).toBe('Test rule (2) (Auto-created)'); + }); + + it(`trims the title correctly if the rule title including the suffix is bigger than ${MAX_TITLE_LENGTH}`, async () => { + mockBulkGetRecords.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [cases[0]] }); + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-1', + }, + ], + }); + + await connectorExecutor.execute({ + ...params, + groupingBy: [], + rule: { ...params.rule, name: 'a'.repeat(MAX_TITLE_LENGTH) }, + }); + + const title = casesClientMock.cases.bulkCreate.mock.calls[0][0].cases[0].title; + + expect(title.length).toBe(MAX_TITLE_LENGTH); + expect(title.includes('(2) (Auto-created)')).toBe(true); + }); + + it(`trims tags that are bigger than ${MAX_LENGTH_PER_TAG} characters`, async () => { + mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [cases[0]] }); + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-1', + }, + ], + }); + + await connectorExecutor.execute({ + ...params, + rule: { ...params.rule, tags: ['a'.repeat(MAX_LENGTH_PER_TAG * 2)] }, + }); + + const tags = casesClientMock.cases.bulkCreate.mock.calls[0][0].cases[0].tags; + + expect(tags).toEqual([ + 'auto-generated', + 'rule:rule-test-id', + 'host.name:A', + 'dest.ip:0.0.0.1', + 'a'.repeat(MAX_LENGTH_PER_TAG), + ]); + }); + + it(`create cases with up to ${MAX_TAGS_PER_CASE} tags`, async () => { + mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [cases[0]] }); + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-1', + }, + ], + }); + + await connectorExecutor.execute({ + ...params, + rule: { ...params.rule, tags: Array(MAX_TAGS_PER_CASE * 2).fill('foo') }, + }); + + const tags = casesClientMock.cases.bulkCreate.mock.calls[0][0].cases[0].tags; + const systemTags = [ + 'auto-generated', + 'rule:rule-test-id', + 'host.name:A', + 'dest.ip:0.0.0.1', + ]; + + expect(tags).toEqual([ + 'auto-generated', + 'rule:rule-test-id', + 'host.name:A', + 'dest.ip:0.0.0.1', + ...Array(MAX_TAGS_PER_CASE - systemTags.length).fill('foo'), + ]); + }); + + it('converts grouping values in tags correctly', async () => { + mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [cases[0]] }); + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-1', + }, + ], + }); + + await connectorExecutor.execute({ + ...params, + alerts: [ + { + _id: 'test-id', + _index: 'test-index', + foo: ['bar', 1, true, {}], + bar: { foo: 'test' }, + baz: 'my value', + }, + ], + groupingBy: ['foo', 'bar', 'baz'], + }); + + const tags = casesClientMock.cases.bulkCreate.mock.calls[0][0].cases[0].tags; + + expect(tags).toEqual([ + 'auto-generated', + 'rule:rule-test-id', + 'foo:["bar",1,true,{}]', + 'bar.foo:test', + 'baz:my value', + 'rule', + 'test', + ]); + }); + + it('does not reopen closed cases if reopenClosedCases=false', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }], + errors: [], + }); + + await connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }); + + expect(casesClientMock.cases.bulkUpdate).not.toHaveBeenCalled(); + }); + + it('reopen closed cases if reopenClosedCases=true', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }, cases[1]], + errors: [], + }); + + await connectorExecutor.execute({ + ...params, + reopenClosedCases: true, + }); + + expect(casesClientMock.cases.bulkUpdate).toHaveBeenCalledWith({ + cases: [{ id: cases[0].id, status: 'open', version: cases[0].version }], + }); + }); + + it('creates new cases if reopenClosedCases=false and there are closed cases', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }, cases[1]], + errors: [], + }); + + mockBulkUpdateRecord.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); + + await connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }); + + expect(mockBulkUpdateRecord).toHaveBeenCalledWith([ + { payload: { counter: 2 }, recordId: 'so-oracle-record-0', version: 'so-version-0' }, + ]); + + expect(casesClientMock.cases.bulkCreate).toHaveBeenCalledWith({ + cases: [ + { + id: 'mock-id-4', + title: 'Test rule (Auto-created)', + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `A` and `dest.ip` equals `0.0.0.1`', + owner: 'cases', + settings: { + syncAlerts: false, + }, + tags: [ + 'auto-generated', + 'rule:rule-test-id', + 'host.name:A', + 'dest.ip:0.0.0.1', + ...rule.tags, + ], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: [], + }, + ], + }); + }); + + describe('Custom Fields', () => { + const mockOwner = params.owner; + const mockConfiguration = [ + { + owner: mockOwner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'text 1', + required: true, + defaultValue: 'default value', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'toggle 1', + required: true, + defaultValue: true, + }, + { + key: 'third_key', + type: CustomFieldTypes.TEXT, + label: 'text 2', + required: true, + // no defaultValue + }, + { + key: 'fourth_key', + type: CustomFieldTypes.TOGGLE, + label: 'toggle 2', + required: true, + // no defaultValue + }, + ], + }, + ]; + const expectedCustomFieldValues = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: 'default value', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: true, + }, + { + key: 'third_key', + type: CustomFieldTypes.TEXT as const, + value: 'N/A', + }, + { + key: 'fourth_key', + type: CustomFieldTypes.TOGGLE as const, + value: false, + }, + ]; + + it('creates non existing cases with required custom fields correctly', async () => { + casesClientMock.configure.get = jest.fn().mockResolvedValue(mockConfiguration); + + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [cases[2]] }); + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [cases[0], cases[1]], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-3', + }, + ], + }); + + await connectorExecutor.execute(params); + + expect(casesClientMock.cases.bulkCreate).toHaveBeenCalledWith({ + cases: [ + { + id: 'mock-id-3', + title: 'Test rule (Auto-created)', + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `B` and `dest.ip` equals `0.0.0.3`', + owner: mockOwner, + settings: { + syncAlerts: false, + }, + tags: [ + 'auto-generated', + 'rule:rule-test-id', + 'host.name:B', + 'dest.ip:0.0.0.3', + ...rule.tags, + ], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: expectedCustomFieldValues, + }, + ], + }); + }); + + it('creates new cases with required custom fields if reopenClosedCases=false and there are closed cases', async () => { + casesClientMock.configure.get = jest.fn().mockResolvedValue(mockConfiguration); + + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }, cases[1]], + errors: [], + }); + + mockBulkUpdateRecord.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); + + await connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }); + + expect(mockBulkUpdateRecord).toHaveBeenCalledWith([ + { payload: { counter: 2 }, recordId: 'so-oracle-record-0', version: 'so-version-0' }, + ]); + + expect(casesClientMock.cases.bulkCreate).toHaveBeenCalledWith({ + cases: [ + { + id: 'mock-id-4', + title: 'Test rule (Auto-created)', + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `A` and `dest.ip` equals `0.0.0.1`', + owner: mockOwner, + settings: { + syncAlerts: false, + }, + tags: [ + 'auto-generated', + 'rule:rule-test-id', + 'host.name:A', + 'dest.ip:0.0.0.1', + ...rule.tags, + ], + + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + customFields: expectedCustomFieldValues, + }, + ], + }); + }); + }); + }); + + describe('Alerts', () => { + it('attach the alerts to the correct cases correctly', async () => { + await connectorExecutor.execute(params); + + expectCasesToHaveTheCorrectAlertsAttachedWithGrouping(casesClientMock); + }); + + it('attach alerts with nested grouping', async () => { + await connectorExecutor.execute({ ...params, alerts: alertsNested }); + + expectCasesToHaveTheCorrectAlertsAttachedWithGrouping(casesClientMock); + }); + + it('attaches alerts to reopened cases', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }], + errors: [], + }); + + casesClientMock.cases.bulkUpdate.mockResolvedValue([ + { ...cases[0], status: CaseStatuses.open }, + ]); + + await connectorExecutor.execute({ + ...params, + reopenClosedCases: true, + }); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + alertId: ['alert-id-0', 'alert-id-2'], + index: ['alert-index-0', 'alert-index-2'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + + it('attaches alerts to new created cases if they were closed', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }], + errors: [], + }); + + mockBulkUpdateRecord.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); + casesClientMock.cases.bulkCreate.mockResolvedValue({ + cases: [{ ...cases[0], id: 'mock-id-4' }], + }); + + await connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-4', + attachments: [ + { + alertId: ['alert-id-0', 'alert-id-2'], + index: ['alert-index-0', 'alert-index-2'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + + it('does not attach alerts to cases that have surpass the limit', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], totalAlerts: MAX_ALERTS_PER_CASE }], + errors: [], + }); + + await connectorExecutor.execute(params); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(0); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Cases with ids "mock-id-1" contain more than 1000 alerts. The new alerts will not be attached to the cases. Total new alerts: 1', + { tags: ['cases-connector', 'rule:rule-test-id'], labels: {} } + ); + }); + + it('does not attach alerts to cases when attaching the new alerts will surpass the limit', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [ + { + ...cases[0], + totalAlerts: MAX_ALERTS_PER_CASE - groupedAlertsWithOracleKey[0].alerts.length + 1, + }, + ], + errors: [], + }); + + await connectorExecutor.execute(params); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(0); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Cases with ids "mock-id-1" contain more than 1000 alerts. The new alerts will not be attached to the cases. Total new alerts: 1', + { tags: ['cases-connector', 'rule:rule-test-id'], labels: {} } + ); + }); + + it('attach alerts to cases when attaching the new alerts will be equal to the limit', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [ + { + ...cases[0], + totalAlerts: MAX_ALERTS_PER_CASE - groupedAlertsWithOracleKey[0].alerts.length, + }, + ], + errors: [], + }); + + await connectorExecutor.execute(params); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); + }); + }); + + describe('Error handling', () => { + it('throws an error when bulk getting records and there are different errors from 404', async () => { + mockBulkGetRecords.mockResolvedValue([ + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'getting records: mockBulkGetRecords error', + statusCode: 409, + error: 'Conflict', + }, + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'Input not accepted', + statusCode: 400, + error: 'Bad request', + }, + ]); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Conflict: getting records: mockBulkGetRecords error"` + ); + + expect(mockBulkCreateRecords).not.toHaveBeenCalled(); + }); + + it('throws an error when bulk creating non found records and there is an error', async () => { + mockBulkCreateRecords.mockResolvedValue([ + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'creating records: mockBulkCreateRecords error', + statusCode: 400, + error: 'Bad request', + }, + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'Version mismatch', + statusCode: 409, + error: 'Conflict', + }, + ]); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Bad request: creating records: mockBulkCreateRecords error"` + ); + + expect(casesClientMock.cases.bulkGet).not.toHaveBeenCalled(); + }); + + it('throws an error when updating the counter if the time window has passed and there is an error', async () => { + dateMathMock.parse.mockImplementation(() => moment('2023-11-10T10:23:42.769Z')); + + mockBulkUpdateRecord.mockResolvedValue([ + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'timeWindow: bulkUpdateRecord error', + statusCode: 400, + error: 'Bad request', + }, + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'Version mismatch', + statusCode: 409, + error: 'Conflict', + }, + ]); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Bad request: timeWindow: bulkUpdateRecord error"` + ); + + expect(casesClientMock.cases.bulkGet).not.toHaveBeenCalled(); + }); + + it('throws an error when bulk getting cases and there are different errors from 404', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [cases[0], cases[1]], + errors: [ + { + error: 'Forbidden', + message: 'getting cases: bulkGet error', + status: 403, + caseId: 'mock-id-3', + }, + { + message: 'Input not accepted', + status: 400, + error: 'Bad request', + caseId: 'mock-id-4', + }, + ], + }); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Forbidden: getting cases: bulkGet error"`); + + expect(casesClientMock.cases.bulkCreate).not.toHaveBeenCalled(); + }); + + it('throws an error when bulk creating non found cases and there is an error', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [cases[0], cases[1]], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-3', + }, + ], + }); + + casesClientMock.cases.bulkCreate.mockRejectedValue( + new CaseError('creating non found cases: bulkCreate error') + ); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"creating non found cases: bulkCreate error"` + ); + + expect(casesClientMock.attachments.bulkCreate).not.toHaveBeenCalled(); + }); + + it('throws an error when reopening cases and there is an error', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }], + errors: [], + }); + + casesClientMock.cases.bulkUpdate.mockRejectedValue( + new CaseError('reopening closed cases: bulkUpdate error') + ); + + await expect(() => + connectorExecutor.execute({ + ...params, + reopenClosedCases: true, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"reopening closed cases: bulkUpdate error"` + ); + + expect(casesClientMock.attachments.bulkCreate).not.toHaveBeenCalled(); + }); + + it('throws an error when creating new cases for closed cases and increasing the counters returns an error', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }], + errors: [], + }); + + mockBulkUpdateRecord.mockResolvedValue([ + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'creating new cases for closed cases: bulkUpdateRecord error', + statusCode: 400, + error: 'Bad request', + }, + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'Version mismatch', + statusCode: 409, + error: 'Conflict', + }, + ]); + + await expect(() => + connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Bad request: creating new cases for closed cases: bulkUpdateRecord error"` + ); + + expect(casesClientMock.cases.bulkCreate).not.toHaveBeenCalled(); + }); + + it('throws an error when creating new cases for closed cases and there is an error', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }], + errors: [], + }); + + mockBulkUpdateRecord.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); + + casesClientMock.cases.bulkCreate.mockRejectedValue( + new CaseError('creating non found cases: bulkCreate error') + ); + + await expect(() => + connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"creating non found cases: bulkCreate error"` + ); + + expect(casesClientMock.attachments.bulkCreate).not.toHaveBeenCalled(); + }); + + it('throws an error if there is an error when attaching alerts to cases', async () => { + casesClientMock.attachments.bulkCreate.mockRejectedValue( + new CaseError('attaching alerts: bulkCreate error') + ); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"attaching alerts: bulkCreate error"`); + }); + + it('throws an error if there is an error when fetching configurations', async () => { + casesClientMock.configure.get = jest + .fn() + .mockRejectedValue(new CaseError('get configuration error')); + + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-1', + }, + ], + }); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"get configuration error"`); + }); + }); + + describe('Non grouped alerts', () => { + it('attaches the non grouped alerts to a case correctly when no alerts have the fields set in groupingBy', async () => { + await connectorExecutor.execute({ + ...params, + groupingBy: ['does.not.exists'], + }); + + expect(mockGetRecordId).toHaveBeenCalledWith({ + ruleId: rule.id, + grouping: { 'does.not.exists': 'unknown' }, + owner, + spaceId: 'default', + }); + + expect(mockBulkGetRecords).toHaveBeenCalledWith(['so-oracle-record-0']); + + expect(mockGetCaseId).toHaveBeenCalledWith({ + ruleId: rule.id, + grouping: { 'does.not.exists': 'unknown' }, + owner, + spaceId: 'default', + counter: 1, + }); + + expect(casesClientMock.cases.bulkGet).toHaveBeenCalledWith({ + ids: ['mock-id-1'], + }); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + alertId: alerts.map((alert) => alert._id), + index: alerts.map((alert) => alert._index), + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + + it('attaches the non grouped alerts to a case correctly when some alerts do not have the fields set in groupingBy', async () => { + mockBulkGetRecords.mockResolvedValue([ + ...oracleRecords, + { + id: 'so-oracle-record-3', + version: 'so-version-1', + counter: 1, + cases: [], + rules: [], + grouping: {}, + createdAt: '2023-10-12T10:23:42.769Z', + updatedAt: '2023-10-12T10:23:42.769Z', + }, + ]); + + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [...cases, { ...cases[2], id: 'mock-id-4' }], + errors: [], + }); + + await connectorExecutor.execute({ + ...params, + alerts: alertsWithNoGrouping, + }); + + expect(mockGetRecordId).toHaveBeenCalledTimes(4); + expect(mockGetRecordId).nthCalledWith(4, { + ruleId: rule.id, + grouping: { + 'dest.ip': 'unknown', + 'host.name': 'unknown', + }, + owner, + spaceId: 'default', + }); + + expect(mockBulkGetRecords).toHaveBeenCalledWith([ + 'so-oracle-record-0', + 'so-oracle-record-1', + 'so-oracle-record-2', + 'so-oracle-record-3', + ]); + + expect(mockGetCaseId).toHaveBeenCalledTimes(4); + expect(mockGetCaseId).nthCalledWith(3, { + ruleId: rule.id, + grouping: { + 'dest.ip': 'unknown', + 'host.name': 'unknown', + }, + owner, + spaceId: 'default', + counter: 1, + }); + + expect(casesClientMock.cases.bulkGet).toHaveBeenCalledWith({ + ids: ['mock-id-1', 'mock-id-2', 'mock-id-3', 'mock-id-4'], + }); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(4); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(3, { + caseId: 'mock-id-3', + attachments: [ + { + type: 'alert', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + alertId: ['alert-id-4', 'alert-id-5'], + index: ['alert-index-4', 'alert-index-5'], + owner: 'securitySolution', + }, + ], + }); + }); + }); + }); + }); + + /** + * In this testing group we test + * only the functionality that differs + * from the testing with grouping + */ + describe('Without grouping', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // @ts-expect-error: other properties are not required + CasesOracleServiceMock.mockImplementation(() => { + return { + getRecordId: mockGetRecordId.mockImplementation( + () => `so-oracle-record-${oracleIdCounter++}` + ), + bulkGetRecords: mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]), + bulkCreateRecord: mockBulkCreateRecords.mockResolvedValue([]), + }; + }); + + // @ts-expect-error: other properties are not required + CasesServiceMock.mockImplementation(() => { + return { + getCaseId: mockGetCaseId.mockImplementation(() => `mock-id-${++caseIdCounter}`), + }; + }); + + casesClientMock.cases.bulkGet.mockResolvedValue({ cases: [cases[0]], errors: [] }); + casesClientMock.attachments.bulkCreate.mockResolvedValue(cases[0]); + + getCasesClient.mockReturnValue(casesClientMock); + + connectorExecutor = new CasesConnectorExecutor({ + logger: mockLogger, + casesOracleService: new CasesOracleServiceMock(), + casesService: new CasesServiceMock(), + casesClient: casesClientMock, + spaceId: 'default', + }); + }); + + describe('Oracle records', () => { + it('generates the oracle keys correctly with no grouping', async () => { + await connectorExecutor.execute({ ...params, groupingBy: [] }); + + expect(mockGetRecordId).toHaveBeenCalledTimes(1); + + expect(mockGetRecordId).nthCalledWith(1, { + ruleId: rule.id, + grouping: {}, + owner, + spaceId: 'default', + }); + }); + + it('gets the oracle records correctly', async () => { + await connectorExecutor.execute({ ...params, groupingBy: [] }); + + expect(mockBulkGetRecords).toHaveBeenCalledWith(['so-oracle-record-0']); + }); + }); + + describe('Cases', () => { + it('generates the case ids correctly', async () => { + await connectorExecutor.execute({ ...params, groupingBy: [] }); + + expect(mockGetCaseId).toHaveBeenCalledTimes(1); + + expect(mockGetCaseId).nthCalledWith(1, { + ruleId: rule.id, + grouping: {}, + owner, + spaceId: 'default', + counter: 1, + }); + }); + + it('gets the cases correctly', async () => { + await connectorExecutor.execute({ ...params, groupingBy: [] }); + + expect(casesClientMock.cases.bulkGet).toHaveBeenCalledWith({ + ids: ['mock-id-1'], + }); + }); + }); + + describe('Alerts', () => { + it('attach all alerts to the same case when the grouping is not defined', async () => { + await connectorExecutor.execute({ ...params, groupingBy: [] }); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + alertId: alerts.map((alert) => alert._id), + index: alerts.map((alert) => alert._index), + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + }); + }); + + describe('Retries', () => { + it('attaches the alerts correctly when bulkGetRecords fails', async () => { + mockBulkGetRecords + .mockResolvedValueOnce([ + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'getting records: mockBulkGetRecords error', + statusCode: 409, + error: 'Conflict', + }, + ]) + .mockResolvedValueOnce(oracleRecords); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Conflict: getting records: mockBulkGetRecords error"` + ); + + resetCounters(); + + // retry + await connectorExecutor.execute(params); + + expectCasesToHaveTheCorrectAlertsAttachedWithGrouping(casesClientMock); + }); + + it('attaches the alerts correctly when bulkCreateRecord fails', async () => { + mockBulkCreateRecords + .mockResolvedValueOnce([ + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'creating records: bulkCreateRecord error', + statusCode: 409, + error: 'Conflict', + }, + ]) + .mockResolvedValueOnce([createdOracleRecord]); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Conflict: creating records: bulkCreateRecord error"` + ); + + resetCounters(); + + // retry + await connectorExecutor.execute(params); + + expectCasesToHaveTheCorrectAlertsAttachedWithGrouping(casesClientMock); + }); + + it('attaches the alerts correctly while creating a record and another node has already created it', async () => { + // the last record in oracleRecords is a 404 + mockBulkGetRecords + .mockResolvedValueOnce(oracleRecords) + .mockResolvedValueOnce([oracleRecords[0], oracleRecords[1], createdOracleRecord]); + + mockBulkCreateRecords.mockResolvedValueOnce([ + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'creating records: bulkCreateRecord error', + statusCode: 409, + error: 'Conflict', + }, + ]); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Conflict: creating records: bulkCreateRecord error"` + ); + + resetCounters(); + + // retry + await connectorExecutor.execute(params); + + // called only once when the conflict occurs + expect(mockBulkCreateRecords).toHaveBeenCalledTimes(1); + expectCasesToHaveTheCorrectAlertsAttachedWithGrouping(casesClientMock); + }); + + it('attaches the alerts correctly when increasing the counter (time window) fails', async () => { + dateMathMock.parse.mockImplementation(() => moment('2023-11-10T10:23:42.769Z')); + + mockBulkUpdateRecord + .mockResolvedValueOnce([ + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'updating records: mockBulkUpdateRecord error', + statusCode: 409, + error: 'Conflict', + }, + ]) + .mockResolvedValueOnce([{ ...oracleRecords[0], counter: 2 }]); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Conflict: updating records: mockBulkUpdateRecord error"` + ); + + resetCounters(); + + mockGetCaseId + .mockReturnValueOnce('mock-id-4') + .mockReturnValueOnce('mock-id-1') + .mockReturnValueOnce('mock-id-2'); + + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [cases[0], cases[1]], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-4', + }, + ], + }); + + casesClientMock.cases.bulkCreate.mockResolvedValue({ + cases: [{ ...cases[0], id: 'mock-id-4' }], + }); + + // retry + await connectorExecutor.execute(params); + + expectCasesToHaveTheCorrectAlertsAttachedWithGroupingAndIncreasedCounter(casesClientMock); + }); + + it('attaches the alerts correctly when increasing the counter (time window) and another node has already increased it', async () => { + dateMathMock.parse.mockImplementation(() => moment('2023-10-11T10:23:42.769Z')); + + mockBulkGetRecords + // counter is 1 + .mockResolvedValueOnce([oracleRecords[0], oracleRecords[1]]) + .mockResolvedValueOnce([ + { + ...createdOracleRecord, + // another node increased the counter + counter: 2, + id: groupedAlertsWithOracleKey[0].oracleKey, + grouping: groupedAlertsWithOracleKey[0].grouping, + version: 'so-version-3', + createdAt: '2023-11-13T10:23:42.769Z', + updatedAt: '2023-11-13T10:23:42.769Z', + }, + oracleRecords[1], + ]); + + // conflict error. Another node had updated the record. + mockBulkUpdateRecord.mockResolvedValueOnce([ + { + id: groupedAlertsWithOracleKey[0].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'updating records: mockBulkUpdateRecord error', + statusCode: 409, + error: 'Conflict', + }, + ]); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Conflict: updating records: mockBulkUpdateRecord error"` + ); + + resetCounters(); + + mockGetCaseId.mockReturnValueOnce('mock-id-4').mockReturnValueOnce('mock-id-2'); + + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [cases[1]], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-4', + }, + ], + }); + + casesClientMock.cases.bulkCreate.mockResolvedValue({ + cases: [{ ...cases[0], id: 'mock-id-4' }], + }); + + // retry + await connectorExecutor.execute(params); + + expect(mockGetCaseId).toHaveBeenCalledTimes(2); + // case ID is constructed with the new counter and the correct grouping + expect(mockGetCaseId).nthCalledWith(1, { + ruleId: rule.id, + grouping: groupedAlertsWithOracleKey[0].grouping, + owner, + spaceId: 'default', + counter: 2, + }); + + expect(mockGetCaseId).nthCalledWith(2, { + ruleId: rule.id, + grouping: groupedAlertsWithOracleKey[1].grouping, + owner, + spaceId: 'default', + counter: 1, + }); + + // called only once when the conflict occurs + expect(mockBulkUpdateRecord).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(2); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-2', + attachments: [ + { + alertId: ['alert-id-1'], + index: ['alert-index-1'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, { + caseId: 'mock-id-4', + attachments: [ + { + alertId: ['alert-id-0', 'alert-id-2'], + index: ['alert-index-0', 'alert-index-2'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + + it('attaches the alerts correctly when creating a case and another node has already created', async () => { + mockBulkGetRecords.mockResolvedValueOnce([ + oracleRecords[0], + oracleRecords[1], + createdOracleRecord, + ]); + + casesClientMock.cases.bulkGet + .mockResolvedValueOnce({ + cases: [cases[0], cases[1]], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: 'mock-id-3', + }, + ], + }) + .mockResolvedValueOnce({ + cases, + errors: [], + }); + + casesClientMock.cases.bulkCreate.mockRejectedValue( + new CaseError('creating non found cases: bulkCreate error') + ); + + await expect(() => + connectorExecutor.execute(params) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"creating non found cases: bulkCreate error"`); + + resetCounters(); + + // retry + await connectorExecutor.execute(params); + + // called only once when the conflict occurs + expect(casesClientMock.cases.bulkCreate).toHaveBeenCalledTimes(1); + + expectCasesToHaveTheCorrectAlertsAttachedWithGrouping(casesClientMock); + }); + + it('attaches the alerts correctly when reopening a case and another node has already reopened it', async () => { + mockBulkGetRecords.mockResolvedValueOnce([oracleRecords[0], oracleRecords[1]]); + + casesClientMock.cases.bulkGet + .mockResolvedValueOnce({ + cases: [{ ...cases[0], status: CaseStatuses.closed }, cases[1]], + errors: [], + }) + .mockResolvedValueOnce({ + cases: [cases[0], cases[1]], + errors: [], + }); + + casesClientMock.cases.bulkUpdate.mockRejectedValue( + new CaseError('reopening closed cases: bulkUpdate error') + ); + + await expect(() => + connectorExecutor.execute({ + ...params, + reopenClosedCases: true, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"reopening closed cases: bulkUpdate error"`); + + resetCounters(); + + // retry + await connectorExecutor.execute({ + ...params, + reopenClosedCases: true, + }); + + // called only once when the conflict occurs + expect(casesClientMock.cases.bulkUpdate).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(2); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + alertId: ['alert-id-0', 'alert-id-2'], + index: ['alert-index-0', 'alert-index-2'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, { + caseId: 'mock-id-2', + attachments: [ + { + alertId: ['alert-id-1'], + index: ['alert-index-1'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + + it('attaches the alerts correctly when increasing the counter (closed case) and another node has already increased it', async () => { + mockBulkGetRecords + .mockResolvedValueOnce([oracleRecords[0], oracleRecords[1]]) + .mockResolvedValueOnce([updatedCounterOracleRecord, oracleRecords[1]]); + + casesClientMock.cases.bulkGet + .mockResolvedValueOnce({ + cases: [{ ...cases[0], status: CaseStatuses.closed }, cases[1]], + errors: [], + }) + .mockResolvedValueOnce({ + cases: [{ ...cases[0], id: 'mock-id-4' }, cases[1]], + errors: [], + }); + + // conflict error. Another node had updated the record. + mockBulkUpdateRecord.mockResolvedValueOnce([ + { + id: groupedAlertsWithOracleKey[0].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'updating records: mockBulkUpdateRecord error', + statusCode: 409, + error: 'Conflict', + }, + ]); + + await expect(() => + connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Conflict: updating records: mockBulkUpdateRecord error"` + ); + + resetCounters(); + + mockGetCaseId.mockReturnValueOnce('mock-id-4').mockReturnValueOnce('mock-id-2'); + + // retry + await connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }); + + // called only once when the conflict occurs + expect(mockBulkUpdateRecord).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(2); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-4', + attachments: [ + { + alertId: ['alert-id-0', 'alert-id-2'], + index: ['alert-index-0', 'alert-index-2'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, { + caseId: 'mock-id-2', + attachments: [ + { + alertId: ['alert-id-1'], + index: ['alert-index-1'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + + it('attaches the alerts correctly when creating a case for a closed and another node has already created it', async () => { + mockBulkGetRecords + .mockResolvedValueOnce([oracleRecords[0], oracleRecords[1]]) + .mockResolvedValueOnce([updatedCounterOracleRecord, oracleRecords[1]]); + + casesClientMock.cases.bulkGet + .mockResolvedValueOnce({ + cases: [{ ...cases[0], status: CaseStatuses.closed }, cases[1]], + errors: [], + }) + .mockResolvedValueOnce({ + cases: [{ ...cases[0], id: 'mock-id-4' }, cases[1]], + errors: [], + }); + + mockBulkUpdateRecord.mockResolvedValueOnce([updatedCounterOracleRecord]); + + casesClientMock.cases.bulkCreate.mockRejectedValue( + new CaseError('creating new case for closed case: bulkCreate error') + ); + + await expect(() => + connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"creating new case for closed case: bulkCreate error"` + ); + + resetCounters(); + + mockGetCaseId.mockReturnValueOnce('mock-id-4').mockReturnValueOnce('mock-id-2'); + + // retry + await connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }); + + // called only once when the conflict occurs + expect(casesClientMock.cases.bulkCreate).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(2); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-4', + attachments: [ + { + alertId: ['alert-id-0', 'alert-id-2'], + index: ['alert-index-0', 'alert-index-2'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, { + caseId: 'mock-id-2', + attachments: [ + { + alertId: ['alert-id-1'], + index: ['alert-index-1'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + + it('attach the alerts correctly when attaching the alerts fail', async () => { + casesClientMock.attachments.bulkCreate.mockRejectedValueOnce( + new CaseError('attaching alerts: bulkCreate error') + ); + + await expect(() => + connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"attaching alerts: bulkCreate error"`); + + resetCounters(); + + // retry + await connectorExecutor.execute({ + ...params, + reopenClosedCases: false, + }); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(6); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + alertId: ['alert-id-0', 'alert-id-2'], + index: ['alert-index-0', 'alert-index-2'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(4, { + caseId: 'mock-id-1', + attachments: [ + { + alertId: ['alert-id-0', 'alert-id-2'], + index: ['alert-index-0', 'alert-index-2'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, { + caseId: 'mock-id-2', + attachments: [ + { + alertId: ['alert-id-1'], + index: ['alert-index-1'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(5, { + caseId: 'mock-id-2', + attachments: [ + { + alertId: ['alert-id-1'], + index: ['alert-index-1'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(3, { + caseId: 'mock-id-3', + attachments: [ + { + alertId: ['alert-id-3'], + index: ['alert-index-3'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(6, { + caseId: 'mock-id-3', + attachments: [ + { + alertId: ['alert-id-3'], + index: ['alert-index-3'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + }); + + describe('Logging', () => { + it('logs a warning when parsing the time window results to error', async () => { + mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); + dateMathMock.parse.mockImplementation(() => undefined); + + await connectorExecutor.execute({ + ...params, + timeWindow: 'invalid', + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + '[CasesConnector][CasesConnectorExecutor][isTimeWindowPassed] Parsing time window error. Parsing value: "invalid"', + { labels: {}, tags: ['cases-connector', 'rule:rule-test-id'] } + ); + }); + + it('logs a warning when the last updated date of the oracle record is not valid', async () => { + mockBulkGetRecords.mockResolvedValue([{ ...oracleRecords[0], updatedAt: 'invalid' }]); + + await connectorExecutor.execute(params); + + expect(mockLogger.warn).toHaveBeenCalledWith( + '[CasesConnector][CasesConnectorExecutor][isTimeWindowPassed] Timestamp "invalid" is not a valid date', + { labels: {}, tags: ['cases-connector', 'rule:rule-test-id'] } + ); + }); + }); + + describe('Circuit breakers', () => { + describe('user defined', () => { + it('generates the oracle keys correctly when the total cases to be open is more than maximumCasesToOpen', async () => { + await connectorExecutor.execute({ + ...params, + maximumCasesToOpen: 1, + }); + + expect(mockGetRecordId).toHaveBeenCalledTimes(1); + expect(mockGetRecordId).nthCalledWith(1, { + ruleId: rule.id, + grouping: {}, + owner, + spaceId: 'default', + }); + }); + + it('generates the case ids correctly when the total cases to be open is more than maximumCasesToOpen', async () => { + await connectorExecutor.execute({ + ...params, + maximumCasesToOpen: 1, + }); + + expect(mockGetCaseId).toHaveBeenCalledTimes(1); + expect(mockGetCaseId).nthCalledWith(1, { + ruleId: rule.id, + grouping: {}, + owner, + spaceId: 'default', + counter: 1, + }); + }); + + it('attach all alerts to the same case when the grouping generates more than maximumCasesToOpen', async () => { + await connectorExecutor.execute({ + ...params, + maximumCasesToOpen: 1, + }); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + type: 'alert', + alertId: ['alert-id-0', 'alert-id-2', 'alert-id-1', 'alert-id-3'], + index: ['alert-index-0', 'alert-index-2', 'alert-index-1', 'alert-index-3'], + rule: { id: 'rule-test-id', name: 'Test rule' }, + owner: 'securitySolution', + }, + ], + }); + }); + + it('logs correctly', async () => { + await connectorExecutor.execute({ + ...params, + maximumCasesToOpen: 1, + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + `[CasesConnector][CasesConnectorExecutor][applyCircuitBreakers] Circuit breaker: Grouping definition would create more than the maximum number of allowed cases 1. Falling back to one case.`, + { labels: {}, tags: ['cases-connector', 'rule:rule-test-id'] } + ); + }); + }); + + describe('hard limits', () => { + const allAlerts = Array.from({ length: MAX_OPEN_CASES + 1 }).map((_, index) => ({ + _id: `alert-id-${index}`, + _index: `alert-index-${index}`, + 'host.name': `host-${index}`, + })); + + it('generates the oracle keys correctly when the total cases to be open is more than MAX_OPEN_CASES', async () => { + await connectorExecutor.execute({ + ...params, + alerts: allAlerts, + groupingBy: ['host.name'], + // MAX_OPEN_CASES < maximumCasesToOpen + maximumCasesToOpen: 20, + }); + + expect(mockGetRecordId).toHaveBeenCalledTimes(1); + expect(mockGetRecordId).nthCalledWith(1, { + ruleId: rule.id, + grouping: {}, + owner, + spaceId: 'default', + }); + }); + + it('generates the case ids correctly when the total cases to be open is more than MAX_OPEN_CASES', async () => { + await connectorExecutor.execute({ + ...params, + alerts: allAlerts, + groupingBy: ['host.name'], + // MAX_OPEN_CASES < maximumCasesToOpen + maximumCasesToOpen: 20, + }); + + expect(mockGetCaseId).toHaveBeenCalledTimes(1); + expect(mockGetCaseId).nthCalledWith(1, { + ruleId: rule.id, + grouping: {}, + owner, + spaceId: 'default', + counter: 1, + }); + }); + + it('attach all alerts to the same case when the grouping generates more than MAX_OPEN_CASES', async () => { + await connectorExecutor.execute({ + ...params, + alerts: allAlerts, + groupingBy: ['host.name'], + // MAX_OPEN_CASES < maximumCasesToOpen + maximumCasesToOpen: 20, + }); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + alertId: allAlerts.map((alert) => alert._id), + index: allAlerts.map((alert) => alert._index), + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + + it('logs correctly', async () => { + await connectorExecutor.execute({ + ...params, + alerts: allAlerts, + groupingBy: ['host.name'], + // MAX_OPEN_CASES < maximumCasesToOpen + maximumCasesToOpen: 20, + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + `[CasesConnector][CasesConnectorExecutor][applyCircuitBreakers] Circuit breaker: Grouping definition would create more than the maximum number of allowed cases 10. Falling back to one case.`, + { labels: {}, tags: ['cases-connector', 'rule:rule-test-id'] } + ); + }); + }); + }); + + describe('Sequence of executions with missing oracle or cases', () => { + const missingDataParams = { + ...params, + alerts: [ + { + _id: 'test-id', + _index: 'test-index', + foo: 'bar', + }, + ], + groupingBy: ['foo'], + }; + + it('oracle counter increases but some cases are missing', async () => { + mockGetRecordId.mockReturnValue(oracleRecords[0].id); + mockBulkGetRecords + .mockResolvedValueOnce([oracleRecords[0]]) + .mockResolvedValueOnce([{ ...oracleRecords[0], counter: 2 }]) + .mockResolvedValueOnce([{ ...oracleRecords[0], counter: 3 }]); + + mockGetCaseId + .mockReturnValueOnce('mock-id-1') + .mockReturnValueOnce('mock-id-2') + .mockReturnValueOnce('mock-id-3'); + + casesClientMock.cases.bulkGet + .mockResolvedValueOnce({ + cases: [cases[0]], + errors: [], + }) + .mockResolvedValueOnce({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: cases[1].id, + }, + ], + }) + .mockResolvedValueOnce({ + cases: [cases[2]], + errors: [], + }); + + casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [cases[1]] }); + + await connectorExecutor.execute(missingDataParams); + await connectorExecutor.execute(missingDataParams); + await connectorExecutor.execute(missingDataParams); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(3); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + type: 'alert', + alertId: ['test-id'], + index: ['test-index'], + rule: { id: 'rule-test-id', name: 'Test rule' }, + owner: 'securitySolution', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, { + caseId: 'mock-id-2', + attachments: [ + { + type: 'alert', + alertId: ['test-id'], + index: ['test-index'], + rule: { id: 'rule-test-id', name: 'Test rule' }, + owner: 'securitySolution', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(3, { + caseId: 'mock-id-3', + attachments: [ + { + type: 'alert', + alertId: ['test-id'], + index: ['test-index'], + rule: { id: 'rule-test-id', name: 'Test rule' }, + owner: 'securitySolution', + }, + ], + }); + }); + + it('oracle record is missing but some cases exists', async () => { + mockGetRecordId.mockReturnValue(oracleRecords[0].id); + mockBulkGetRecords + .mockResolvedValueOnce([ + { + id: oracleRecords[0].id, + type: CASE_RULES_SAVED_OBJECT, + message: 'Not found', + statusCode: 404, + error: 'Not found', + }, + ]) + .mockResolvedValueOnce([oracleRecords[0]]) + .mockResolvedValueOnce([{ ...oracleRecords[0], counter: 2 }]); + + mockBulkCreateRecords.mockResolvedValue([oracleRecords[0]]); + + mockGetCaseId + .mockReturnValueOnce('mock-id-1') + .mockReturnValueOnce('mock-id-2') + .mockReturnValueOnce('mock-id-3'); + + casesClientMock.cases.bulkGet + .mockResolvedValueOnce({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: cases[0].id, + }, + ], + }) + .mockResolvedValueOnce({ + cases: [cases[1]], + errors: [], + }) + .mockResolvedValueOnce({ + cases: [], + errors: [ + { + error: 'Not found', + message: 'Not found', + status: 404, + caseId: cases[2].id, + }, + ], + }); + + casesClientMock.cases.bulkCreate + .mockResolvedValueOnce({ cases: [cases[0]] }) + .mockResolvedValueOnce({ cases: [cases[2]] }); + + await connectorExecutor.execute(missingDataParams); + await connectorExecutor.execute(missingDataParams); + await connectorExecutor.execute(missingDataParams); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(3); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + type: 'alert', + alertId: ['test-id'], + index: ['test-index'], + rule: { id: 'rule-test-id', name: 'Test rule' }, + owner: 'securitySolution', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, { + caseId: 'mock-id-2', + attachments: [ + { + type: 'alert', + alertId: ['test-id'], + index: ['test-index'], + rule: { id: 'rule-test-id', name: 'Test rule' }, + owner: 'securitySolution', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(3, { + caseId: 'mock-id-3', + attachments: [ + { + type: 'alert', + alertId: ['test-id'], + index: ['test-index'], + rule: { id: 'rule-test-id', name: 'Test rule' }, + owner: 'securitySolution', + }, + ], + }); + }); + + it('increase oracle counter but is missing', async () => { + const nonFoundRecord = { + id: oracleRecords[0].id, + type: CASE_RULES_SAVED_OBJECT, + message: 'Not found', + statusCode: 404, + error: 'Not found', + }; + + dateMathMock.parse + // time window has passed. should increase the counter + .mockImplementationOnce(() => moment('2023-11-10T10:23:42.769Z')) + // time window has not passed. counter should not be increased + .mockImplementationOnce(() => moment('2023-10-09T10:23:42.769Z')); + + mockGetRecordId.mockReturnValue(oracleRecords[0].id); + mockBulkGetRecords + .mockResolvedValueOnce([oracleRecords[0]]) + .mockResolvedValueOnce([nonFoundRecord]); + + mockBulkCreateRecords.mockResolvedValueOnce(oracleRecords[0]); + mockBulkUpdateRecord.mockResolvedValueOnce(nonFoundRecord); + + mockGetCaseId.mockReturnValueOnce('mock-id-1'); + + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [cases[0]], + errors: [], + }); + + await connectorExecutor.execute(missingDataParams); + await connectorExecutor.execute(missingDataParams); + + expect(mockBulkUpdateRecord).toBeCalledTimes(1); + expect(mockBulkUpdateRecord).toHaveBeenCalledWith([ + { payload: { counter: 2 }, recordId: 'so-oracle-record-0', version: 'so-version-0' }, + ]); + + expect(mockBulkCreateRecords).toBeCalledTimes(1); + expect(mockBulkCreateRecords).toHaveBeenCalledWith([ + { + payload: { + grouping: { + foo: 'bar', + }, + rules: [ + { + id: 'rule-test-id', + }, + ], + }, + recordId: 'so-oracle-record-0', + }, + ]); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledWith({ + caseId: 'mock-id-1', + attachments: [ + { + type: 'alert', + alertId: ['test-id'], + index: ['test-index'], + rule: { id: 'rule-test-id', name: 'Test rule' }, + owner: 'securitySolution', + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_connector_executor.ts b/x-pack/plugins/cases/server/connectors/cases/cases_connector_executor.ts new file mode 100644 index 0000000000000..a5f07a9b65fc7 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_connector_executor.ts @@ -0,0 +1,1103 @@ +/* + * 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 stringify from 'json-stable-stringify'; +import pMap from 'p-map'; +import { get, partition, pick } from 'lodash'; +import dateMath from '@kbn/datemath'; +import { CaseStatuses } from '@kbn/cases-components'; +import type { SavedObjectError } from '@kbn/core-saved-objects-common'; +import type { Logger } from '@kbn/core/server'; +import { getFlattenedObject } from '@kbn/std'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import { + MAX_ALERTS_PER_CASE, + MAX_LENGTH_PER_TAG, + MAX_TAGS_PER_CASE, + MAX_TITLE_LENGTH, +} from '../../../common/constants'; +import type { BulkCreateCasesRequest } from '../../../common/types/api'; +import type { Case } from '../../../common'; +import { ConnectorTypes, AttachmentType } from '../../../common'; +import { + INITIAL_ORACLE_RECORD_COUNTER, + MAX_CONCURRENT_ES_REQUEST, + MAX_OPEN_CASES, +} from './constants'; +import type { BulkCreateOracleRecordRequest, CasesConnectorRunParams, OracleRecord } from './types'; +import type { CasesOracleService } from './cases_oracle_service'; +import { + convertValueToString, + partitionByNonFoundErrors, + partitionRecordsByError, + buildRequiredCustomFieldsForRequest, +} from './utils'; +import type { CasesService } from './cases_service'; +import type { CasesClient } from '../../client'; +import type { BulkCreateArgs as BulkCreateAlertsReq } from '../../client/attachments/types'; +import { CasesConnectorError } from './cases_connector_error'; + +interface CasesConnectorExecutorParams { + logger: Logger; + casesOracleService: CasesOracleService; + casesService: CasesService; + casesClient: CasesClient; + spaceId: string; +} + +interface GroupedAlerts { + alerts: CasesConnectorRunParams['alerts']; + grouping: Record; +} + +type GroupedAlertsWithOracleKey = GroupedAlerts & { oracleKey: string }; +type GroupedAlertsWithOracleRecords = GroupedAlertsWithOracleKey & { oracleRecord: OracleRecord }; +type GroupedAlertsWithCaseId = GroupedAlertsWithOracleRecords & { caseId: string }; +type GroupedAlertsWithCases = GroupedAlertsWithCaseId & { theCase: Case }; + +export class CasesConnectorExecutor { + private readonly logger: Logger; + private readonly casesOracleService: CasesOracleService; + private readonly casesService: CasesService; + private readonly casesClient: CasesClient; + private readonly spaceId: string; + + constructor({ + logger, + casesOracleService, + casesService, + casesClient, + spaceId, + }: CasesConnectorExecutorParams) { + this.logger = logger; + this.casesOracleService = casesOracleService; + this.casesService = casesService; + this.casesClient = casesClient; + this.spaceId = spaceId; + } + + public async execute(params: CasesConnectorRunParams) { + const { alerts, groupingBy } = params; + + const groupedAlerts = this.groupAlerts({ params, alerts, groupingBy }); + const groupedAlertsWithCircuitBreakers = this.applyCircuitBreakers(params, groupedAlerts); + + if (groupedAlertsWithCircuitBreakers.length === 0) { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor] Grouping did not produce any alerts. Skipping execution.`, + this.getLogMetadata(params) + ); + + return; + } + + /** + * Based on the rule ID, the grouping, the owner, the space ID, + * the oracle record ID is generated + */ + const groupedAlertsWithOracleKey = this.generateOracleKeys( + params, + groupedAlertsWithCircuitBreakers + ); + + /** + * Gets all records by the IDs that produces in generateOracleKeys. + * If a record does not exist it will create the record. + * A record does not exist if it is the first time the connector run for a specific grouping. + * The returned map will contain all records old and new. + */ + const oracleRecordsMap = await this.upsertOracleRecords(params, groupedAlertsWithOracleKey); + + /** + * If the time window has passed for a case we need to create a new case. + * To do that we need to increase the record counter by one. Increasing the + * counter will generate a new case ID for the same grouping. + * The returned map contain all records with their counters updated correctly + */ + const oracleRecordMapWithTimeWindowHandled = await this.handleTimeWindow( + params, + oracleRecordsMap + ); + + /** + * Based on the rule ID, the grouping, the owner, the space ID, + * and the counter of the oracle record the case ID is generated + */ + const groupedAlertsWithCaseId = this.generateCaseIds( + params, + oracleRecordMapWithTimeWindowHandled + ); + + /** + * Gets all records by the IDs that produces in generateCaseIds. + * If a case does not exist it will create the case. + * A case does not exist if it is the first time the connector run for a specific grouping + * or the time window has elapsed and a new one should be created for the same grouping. + * The returned map will contain all cases old and new. + */ + const groupedAlertsWithCases = await this.upsertCases(params, groupedAlertsWithCaseId); + + /** + * A user can configure how to handle closed cases. Based on the configuration + * we open the closed cases by updating their status or we create new cases by + * increasing the counter of the corresponding oracle record, generating the new + * case ID, and creating the new case. + * The map contains all cases updated and new without any remaining closed case. + */ + const groupedAlertsWithClosedCasesHandled = await this.handleClosedCases( + params, + groupedAlertsWithCases + ); + + /** + * Now that all cases are fetched or created per grouping, we attach the alerts + * to the corresponding cases. + */ + await this.attachAlertsToCases(groupedAlertsWithClosedCasesHandled, params); + } + + private groupAlerts({ + params, + alerts, + groupingBy, + }: Pick & { + params: CasesConnectorRunParams; + }): GroupedAlerts[] { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][groupAlerts] Grouping ${alerts.length} alerts`, + this.getLogMetadata(params, { labels: { groupingBy }, tags: ['case-connector:groupAlerts'] }) + ); + + const uniqueGroupingByFields = Array.from(new Set(groupingBy)); + const groupingMap = new Map(); + + /** + * We are interested in alerts that have a value for any + * of the groupingBy fields defined by the users. All other + * alerts will not be attached to any case. + */ + const [alertsWithAllGroupingFields, noGroupedAlerts] = partition(alerts, (alert) => + uniqueGroupingByFields.every((groupingByField) => Boolean(get(alert, groupingByField, null))) + ); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][groupAlerts] Total alerts to be grouped: ${alertsWithAllGroupingFields.length} out of ${alerts.length}`, + this.getLogMetadata(params, { tags: ['case-connector:groupAlerts'] }) + ); + + for (const alert of alertsWithAllGroupingFields) { + const alertWithOnlyTheGroupingFields = pick(alert, uniqueGroupingByFields); + const groupingKey = stringify(alertWithOnlyTheGroupingFields); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][groupAlerts] Alert ${alert._id} got grouped into bucket with ID ${groupingKey}`, + this.getLogMetadata(params, { tags: ['case-connector:groupAlerts', groupingKey] }) + ); + + if (groupingMap.has(groupingKey)) { + groupingMap.get(groupingKey)?.alerts.push(alert); + } else { + groupingMap.set(groupingKey, { alerts: [alert], grouping: alertWithOnlyTheGroupingFields }); + } + } + + if (noGroupedAlerts.length > 0) { + const noGroupedGrouping = this.generateNoGroupAlertGrouping(params.groupingBy); + + groupingMap.set(stringify(noGroupedGrouping), { + alerts: noGroupedAlerts, + grouping: noGroupedGrouping, + }); + } + + return Array.from(groupingMap.values()); + } + + private generateNoGroupAlertGrouping = (groupingBy: string[]) => { + const noGroupedGrouping = groupingBy.reduce((acc, field) => { + acc[field] = 'unknown'; + + return acc; + }, {} as Record); + + return noGroupedGrouping; + }; + + private applyCircuitBreakers( + params: CasesConnectorRunParams, + groupedAlerts: GroupedAlerts[] + ): GroupedAlerts[] { + if (groupedAlerts.length > params.maximumCasesToOpen || groupedAlerts.length > MAX_OPEN_CASES) { + const maxCasesCircuitBreaker = Math.min(params.maximumCasesToOpen, MAX_OPEN_CASES); + + this.logger.warn( + `[CasesConnector][CasesConnectorExecutor][applyCircuitBreakers] Circuit breaker: Grouping definition would create more than the maximum number of allowed cases ${maxCasesCircuitBreaker}. Falling back to one case.`, + this.getLogMetadata(params) + ); + + return this.removeGrouping(groupedAlerts); + } + + return groupedAlerts; + } + + private removeGrouping(groupedAlerts: GroupedAlerts[]): GroupedAlerts[] { + const allAlerts = groupedAlerts.map(({ alerts }) => alerts).flat(); + + return [{ alerts: allAlerts, grouping: {} }]; + } + + private generateOracleKeys( + params: CasesConnectorRunParams, + groupedAlerts: GroupedAlerts[] + ): Map { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][generateOracleKeys] Generating ${groupedAlerts.length} oracle keys`, + this.getLogMetadata(params, { tags: ['case-connector:generateOracleKeys'] }) + ); + + const { rule, owner } = params; + + const oracleMap = new Map(); + + for (const { grouping, alerts } of groupedAlerts) { + const getRecordIdParams = { + ruleId: rule.id, + grouping, + owner, + spaceId: this.spaceId, + }; + + const oracleKey = this.casesOracleService.getRecordId(getRecordIdParams); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][generateOracleKeys] Oracle key ${oracleKey} generated`, + this.getLogMetadata(params, { + labels: { params: getRecordIdParams }, + tags: ['case-connector:generateOracleKeys', oracleKey], + }) + ); + + oracleMap.set(oracleKey, { oracleKey, grouping, alerts }); + } + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][generateOracleKeys] Total of oracles keys generated ${oracleMap.size}`, + this.getLogMetadata(params, { tags: ['case-connector:generateOracleKeys'] }) + ); + + return oracleMap; + } + + private async upsertOracleRecords( + params: CasesConnectorRunParams, + groupedAlertsWithOracleKey: Map + ): Promise> { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertOracleRecords] Upserting ${groupedAlertsWithOracleKey.size} oracle records`, + this.getLogMetadata(params, { tags: ['case-connector:upsertOracleRecords'] }) + ); + + const bulkCreateReq: BulkCreateOracleRecordRequest = []; + const oracleRecordMap = new Map(); + + const addRecordToMap = (oracleRecords: OracleRecord[]) => { + for (const record of oracleRecords) { + if (groupedAlertsWithOracleKey.has(record.id)) { + const data = groupedAlertsWithOracleKey.get(record.id) as GroupedAlertsWithCaseId; + oracleRecordMap.set(record.id, { ...data, oracleRecord: record }); + } + } + }; + + const ids = Array.from(groupedAlertsWithOracleKey.values()).map(({ oracleKey }) => oracleKey); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertOracleRecords] Getting oracle records with ids ${ids}`, + this.getLogMetadata(params, { tags: ['case-connector:upsertOracleRecords', ...ids] }) + ); + + const bulkGetRes = await this.casesOracleService.bulkGetRecords(ids); + const [bulkGetValidRecords, bulkGetRecordsErrors] = partitionRecordsByError(bulkGetRes); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertOracleRecords] The total number of valid oracle records is ${bulkGetValidRecords.length} and the total number of errors while getting the records is ${bulkGetRecordsErrors.length}`, + this.getLogMetadata(params, { + labels: { + total: ids.length, + success: bulkGetValidRecords.length, + errors: bulkGetRecordsErrors.length, + }, + tags: ['case-connector:upsertOracleRecords'], + }) + ); + + addRecordToMap(bulkGetValidRecords); + + if (bulkGetRecordsErrors.length === 0) { + return oracleRecordMap; + } + + const [nonFoundErrors, restOfErrors] = partitionByNonFoundErrors(bulkGetRecordsErrors); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertOracleRecords] The total number of non found oracle records is ${nonFoundErrors.length} and the total number of the rest of errors while getting the records is ${restOfErrors.length}`, + this.getLogMetadata(params, { + labels: { + nonFoundErrors: nonFoundErrors.length, + restOfErrors: restOfErrors.length, + }, + tags: ['case-connector:upsertOracleRecords'], + }) + ); + + this.handleAndThrowErrors(restOfErrors); + + if (nonFoundErrors.length === 0) { + return oracleRecordMap; + } + + for (const error of nonFoundErrors) { + if (error.id && groupedAlertsWithOracleKey.has(error.id)) { + const record = groupedAlertsWithOracleKey.get(error.id); + bulkCreateReq.push({ + recordId: error.id, + payload: { + rules: [{ id: params.rule.id }], + grouping: record?.grouping ?? {}, + }, + }); + } + } + + const idsToCreate = bulkCreateReq.map(({ recordId }) => recordId); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertOracleRecords] Creating oracle records with ids ${idsToCreate}`, + this.getLogMetadata(params, { tags: ['case-connector:upsertOracleRecords', ...idsToCreate] }) + ); + + const bulkCreateRes = await this.casesOracleService.bulkCreateRecord(bulkCreateReq); + const [bulkCreateValidRecords, bulkCreateErrors] = partitionRecordsByError(bulkCreateRes); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertOracleRecords] The total number of created oracle records is ${bulkCreateValidRecords.length} and the total number of errors while creating the records is ${bulkCreateErrors.length}`, + this.getLogMetadata(params, { + labels: { + total: idsToCreate.length, + success: bulkCreateValidRecords.length, + errors: bulkCreateErrors.length, + }, + tags: ['case-connector:upsertOracleRecords'], + }) + ); + + this.handleAndThrowErrors(bulkCreateErrors); + + addRecordToMap(bulkCreateValidRecords); + + return oracleRecordMap; + } + + private async handleTimeWindow( + params: CasesConnectorRunParams, + oracleRecordMap: Map + ) { + const { timeWindow } = params; + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][handleTimeWindow] Handling time window ${timeWindow}`, + this.getLogMetadata(params, { tags: ['case-connector:handleTimeWindow'] }) + ); + + const oracleRecordMapWithIncreasedCounters = new Map(oracleRecordMap); + + const recordsToIncreaseCounter = Array.from(oracleRecordMap.values()) + .filter(({ oracleRecord }) => + this.isTimeWindowPassed( + params, + timeWindow, + oracleRecord.updatedAt ?? oracleRecord.createdAt + ) + ) + .map(({ oracleRecord }) => oracleRecord); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][handleTimeWindow] Total oracle records where the time window has passed and their counter will be increased ${recordsToIncreaseCounter.length}`, + this.getLogMetadata(params, { + tags: ['case-connector:handleTimeWindow', ...recordsToIncreaseCounter.map(({ id }) => id)], + }) + ); + + const bulkUpdateValidRecords = await this.increaseOracleRecordCounter( + params, + recordsToIncreaseCounter + ); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][handleTimeWindow] Total oracle records where their counter got increased ${bulkUpdateValidRecords.length}`, + this.getLogMetadata(params, { tags: ['case-connector:handleTimeWindow'] }) + ); + + for (const res of bulkUpdateValidRecords) { + if (oracleRecordMap.has(res.id)) { + const data = oracleRecordMap.get(res.id) as GroupedAlertsWithOracleRecords; + oracleRecordMapWithIncreasedCounters.set(res.id, { ...data, oracleRecord: res }); + } + } + + return oracleRecordMapWithIncreasedCounters; + } + + private async increaseOracleRecordCounter( + params: CasesConnectorRunParams, + oracleRecords: OracleRecord[] + ): Promise { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][increaseOracleRecordCounter] Increasing the counters of ${oracleRecords.length} oracle records`, + this.getLogMetadata(params, { tags: ['case-connector:increaseOracleRecordCounter'] }) + ); + + if (oracleRecords.length === 0) { + return []; + } + + const bulkUpdateReq = oracleRecords.map((record) => ({ + recordId: record.id, + version: record.version, + /** + * TODO: Add new cases or any other related info + */ + payload: { counter: record.counter + 1 }, + })); + + const idsToUpdate = bulkUpdateReq.map(({ recordId }) => recordId); + + const bulkUpdateRes = await this.casesOracleService.bulkUpdateRecord(bulkUpdateReq); + const [bulkUpdateValidRecords, bulkUpdateErrors] = partitionRecordsByError(bulkUpdateRes); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertOracleRecords] The total number of updated oracle records is ${bulkUpdateValidRecords.length} and the total number of errors while updating is ${bulkUpdateErrors.length}`, + this.getLogMetadata(params, { + labels: { + total: idsToUpdate.length, + success: bulkUpdateValidRecords.length, + errors: bulkUpdateErrors.length, + }, + tags: ['case-connector:increaseOracleRecordCounter', ...idsToUpdate], + }) + ); + + this.handleAndThrowErrors(bulkUpdateErrors); + + return bulkUpdateValidRecords; + } + + private isTimeWindowPassed( + params: CasesConnectorRunParams, + timeWindow: string, + counterLastUpdatedAt: string + ) { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][isTimeWindowPassed] Validating the time window ${timeWindow} against the timestamp of the last update of the oracle record ${counterLastUpdatedAt}`, + this.getLogMetadata(params, { tags: ['case-connector:isTimeWindowPassed'] }) + ); + + const parsedDate = dateMath.parse(`now-${timeWindow}`); + + /** + * TODO: Should we throw? Should we return true to create a new case? + */ + if (!parsedDate || !parsedDate.isValid()) { + this.logger.warn( + `[CasesConnector][CasesConnectorExecutor][isTimeWindowPassed] Parsing time window error. Parsing value: "${timeWindow}"`, + this.getLogMetadata(params) + ); + + return false; + } + + const counterLastUpdatedAtAsDate = new Date(counterLastUpdatedAt); + + /** + * TODO: Should we throw? Should we return true to create a new case? + */ + if (isNaN(counterLastUpdatedAtAsDate.getTime())) { + this.logger.warn( + `[CasesConnector][CasesConnectorExecutor][isTimeWindowPassed] Timestamp "${counterLastUpdatedAt}" is not a valid date`, + this.getLogMetadata(params) + ); + + return false; + } + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][isTimeWindowPassed] Time window has passed ${ + counterLastUpdatedAtAsDate < parsedDate.toDate() + }`, + this.getLogMetadata(params, { tags: ['case-connector:isTimeWindowPassed'] }) + ); + + return counterLastUpdatedAtAsDate < parsedDate.toDate(); + } + + private generateCaseIds( + params: CasesConnectorRunParams, + groupedAlertsWithOracleRecords: Map + ): Map { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][generateCaseIds] Generating ${groupedAlertsWithOracleRecords.size} case IDs`, + this.getLogMetadata(params, { tags: ['case-connector:generateCaseIds'] }) + ); + + const { rule, owner } = params; + + const casesMap = new Map(); + + for (const [recordId, entry] of groupedAlertsWithOracleRecords.entries()) { + const getCaseIdParams = { + ruleId: rule.id, + grouping: entry.grouping, + owner, + spaceId: this.spaceId, + counter: entry.oracleRecord.counter, + }; + + const caseId = this.casesService.getCaseId(getCaseIdParams); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][generateCaseIds] Case ID ${caseId} generated with params ${JSON.stringify( + getCaseIdParams + )}`, + this.getLogMetadata(params, { + labels: { params: getCaseIdParams }, + tags: ['case-connector:generateCaseIds', caseId], + }) + ); + + casesMap.set(caseId, { + caseId, + alerts: entry.alerts, + grouping: entry.grouping, + oracleKey: recordId, + oracleRecord: entry.oracleRecord, + }); + } + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][generateCaseIds] Total of case IDs generated ${casesMap.size}`, + this.getLogMetadata(params, { tags: ['case-connector:generateCaseIds'] }) + ); + + return casesMap; + } + + private async upsertCases( + params: CasesConnectorRunParams, + groupedAlertsWithCaseId: Map + ): Promise> { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertCases] Upserting ${groupedAlertsWithCaseId.size} cases`, + this.getLogMetadata(params, { tags: ['case-connector:upsertCases'] }) + ); + + const bulkCreateReq = []; + const casesMap = new Map(); + + const ids = Array.from(groupedAlertsWithCaseId.values()).map(({ caseId }) => caseId); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertCases] Getting cases with ids ${ids}`, + this.getLogMetadata(params, { tags: ['case-connector:upsertCases', ...ids] }) + ); + + const { cases, errors } = await this.casesClient.cases.bulkGet({ ids }); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertCases] The total number of cases is ${cases.length} and the total number of errors while getting the cases is ${errors.length}`, + this.getLogMetadata(params, { + labels: { + total: ids.length, + success: cases.length, + errors: errors.length, + }, + tags: ['case-connector:upsertCases'], + }) + ); + + for (const theCase of cases) { + if (groupedAlertsWithCaseId.has(theCase.id)) { + const data = groupedAlertsWithCaseId.get(theCase.id) as GroupedAlertsWithCaseId; + casesMap.set(theCase.id, { ...data, theCase }); + } + } + + if (errors.length === 0) { + return casesMap; + } + + const [nonFoundErrors, restOfErrors] = partitionByNonFoundErrors( + /** + * The format of error returned from bulkGet is different + * from what expected. We need to transform to a SavedObjectError + */ + errors.map((error) => ({ ...error, statusCode: error.status ?? 500, id: error.caseId })) + ); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertCases] The total number of non found cases is ${nonFoundErrors.length} and the total number of the rest of errors while getting the cases is ${restOfErrors.length}`, + this.getLogMetadata(params, { + labels: { + nonFoundErrors: nonFoundErrors.length, + restOfErrors: restOfErrors.length, + }, + tags: ['case-connector:upsertCases'], + }) + ); + + this.handleAndThrowErrors(restOfErrors); + + if (nonFoundErrors.length === 0) { + return casesMap; + } + + const customFieldsConfigurationMap = await this.getCustomFieldsConfiguration(); + + for (const error of nonFoundErrors) { + if (groupedAlertsWithCaseId.has(error.caseId)) { + const data = groupedAlertsWithCaseId.get(error.caseId) as GroupedAlertsWithCaseId; + + bulkCreateReq.push( + this.getCreateCaseRequest(params, data, customFieldsConfigurationMap.get(params.owner)) + ); + } + } + + const idsToCreate = bulkCreateReq.map(({ id }) => id); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertCases] Creating cases with ids ${idsToCreate}`, + this.getLogMetadata(params, { tags: ['case-connector:upsertCases', ...idsToCreate] }) + ); + + /** + * cases.bulkCreate throws an error on errors + */ + const bulkCreateCasesResponse = await this.casesClient.cases.bulkCreate({ + cases: bulkCreateReq, + }); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][upsertCases] The total number of created cases is ${bulkCreateCasesResponse.cases.length}`, + this.getLogMetadata(params, { + labels: { + total: bulkCreateReq.length, + }, + tags: ['case-connector:upsertCases'], + }) + ); + + for (const theCase of bulkCreateCasesResponse.cases) { + if (groupedAlertsWithCaseId.has(theCase.id)) { + const data = groupedAlertsWithCaseId.get(theCase.id) as GroupedAlertsWithCaseId; + casesMap.set(theCase.id, { ...data, theCase }); + } + } + + return casesMap; + } + + private getCreateCaseRequest( + params: CasesConnectorRunParams, + groupingData: GroupedAlertsWithCaseId, + customFieldsConfigurations?: CustomFieldsConfiguration + ): Omit & { id: string } { + const { grouping, caseId, oracleRecord } = groupingData; + + const ruleName = params.rule.ruleUrl + ? `[${params.rule.name}](${params.rule.ruleUrl})` + : params.rule.name; + + const groupingDescription = this.getGroupingDescription(grouping); + const description = `This case is auto-created by ${ruleName}. \n\n Grouping: ${groupingDescription}`; + + const requiredCustomFields = buildRequiredCustomFieldsForRequest(customFieldsConfigurations); + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][getCreateCaseRequest] Built ${requiredCustomFields.length} required custom fields for case with id ${caseId}`, + this.getLogMetadata(params, { + labels: { + caseId, + totalCreatedCustomFields: requiredCustomFields.length, + }, + tags: ['case-connector:getCreateCaseRequest'], + }) + ); + + return { + id: caseId, + description, + tags: this.getCaseTags(params, grouping), + title: this.getCasesTitle(params.rule.name, oracleRecord.counter), + connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, + /** + * TODO: Turn on for Security solution + */ + settings: { syncAlerts: false }, + owner: params.owner, + customFields: requiredCustomFields, + }; + } + + private getCasesTitle(ruleName: string, oracleCounter: number) { + const suffix = + oracleCounter === INITIAL_ORACLE_RECORD_COUNTER + ? '(Auto-created)' + : `(${oracleCounter}) (Auto-created)`; + + const ruleNameTrimmed = ruleName.slice(0, MAX_TITLE_LENGTH - suffix.length - 1); + + return `${ruleNameTrimmed} ${suffix}`; + } + + private getGroupingDescription(grouping: GroupedAlerts['grouping']) { + const flattenGrouping = getFlattenedObject(grouping); + + return Object.entries(flattenGrouping) + .map(([key, value]) => { + const keyAsCodeBlock = `\`${key}\``; + const valueAsCodeBlock = `\`${convertValueToString(value)}\``; + + return `${keyAsCodeBlock} equals ${valueAsCodeBlock}`; + }) + .join(' and '); + } + + private getCaseTags(params: CasesConnectorRunParams, grouping: GroupedAlerts['grouping']) { + const ruleTags = Array.isArray(params.rule.tags) ? params.rule.tags : []; + + return [ + 'auto-generated', + `rule:${params.rule.id}`, + ...this.getGroupingAsTags(grouping), + ...ruleTags, + ] + .splice(0, MAX_TAGS_PER_CASE) + .map((tag) => tag.slice(0, MAX_LENGTH_PER_TAG)); + } + + private getGroupingAsTags(grouping: GroupedAlerts['grouping']) { + const flattenGrouping = getFlattenedObject(grouping); + return Object.entries(flattenGrouping).map( + ([key, value]) => `${key}:${convertValueToString(value)}` + ); + } + + private async handleClosedCases( + params: CasesConnectorRunParams, + casesMap: Map + ) { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][handleClosedCases] Handling closed cases with reopenClosedCases set to ${params.reopenClosedCases}`, + this.getLogMetadata(params, { tags: ['case-connector:handleClosedCases'] }) + ); + + const entriesWithClosedCases = Array.from(casesMap.values()).filter( + (theCase) => theCase.theCase.status === CaseStatuses.closed + ); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][handleClosedCases] Closed cases ${entriesWithClosedCases.length}`, + this.getLogMetadata(params, { tags: ['case-connector:handleClosedCases'] }) + ); + + if (entriesWithClosedCases.length === 0) { + return casesMap; + } + + const res = params.reopenClosedCases + ? await this.reopenClosedCases(params, entriesWithClosedCases, casesMap) + : await this.createNewCasesOutOfClosedCases(params, entriesWithClosedCases, casesMap); + + /** + * The initial map contained the closed cases. We need to remove them to + * avoid attaching alerts to a close case + */ + return new Map([...res].filter(([_, record]) => record.theCase.status !== CaseStatuses.closed)); + } + + private async reopenClosedCases( + params: CasesConnectorRunParams, + closedCasesEntries: GroupedAlertsWithCases[], + casesMap: Map + ): Promise> { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][reopenClosedCases] Total closed cases to reopen ${closedCasesEntries.length}`, + this.getLogMetadata(params, { tags: ['case-connector:reopenClosedCases'] }) + ); + + const casesMapWithClosedCasesOpened = new Map(casesMap); + + const bulkUpdateReq = closedCasesEntries.map((entry) => ({ + id: entry.theCase.id, + version: entry.theCase.version, + status: CaseStatuses.open, + })); + + const idsToReopen = bulkUpdateReq.map(({ id }) => id); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][reopenClosedCases] Reopening total ${bulkUpdateReq.length} closed cases with ids ${idsToReopen}`, + this.getLogMetadata(params, { tags: ['case-connector:reopenClosedCases', ...idsToReopen] }) + ); + + /** + * cases.bulkUpdate throws an error on errors + */ + const bulkUpdateCasesResponse = await this.casesClient.cases.bulkUpdate({ + cases: bulkUpdateReq, + }); + + for (const res of bulkUpdateCasesResponse) { + if (casesMap.has(res.id)) { + const data = casesMap.get(res.id) as GroupedAlertsWithCases; + casesMapWithClosedCasesOpened.set(res.id, { ...data, theCase: res }); + } + } + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][reopenClosedCases] The total number of cases that got reopened is ${bulkUpdateCasesResponse.length}`, + this.getLogMetadata(params, { + labels: { + total: bulkUpdateCasesResponse.length, + }, + tags: ['case-connector:reopenClosedCases'], + }) + ); + + return casesMapWithClosedCasesOpened; + } + + private async createNewCasesOutOfClosedCases( + params: CasesConnectorRunParams, + closedCasesEntries: GroupedAlertsWithCases[], + casesMap: Map + ): Promise> { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][createNewCasesOutOfClosedCases] Creating new cases for closed cases ${closedCasesEntries.length}`, + this.getLogMetadata(params, { tags: ['case-connector:createNewCasesOutOfClosedCases'] }) + ); + + const casesMapWithNewCases = new Map(casesMap); + const casesMapAsArray = Array.from(casesMap.values()); + + const findEntryByOracleRecord = (oracleId: string) => { + return casesMapAsArray.find((record) => record.oracleRecord.id === oracleId); + }; + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][createNewCasesOutOfClosedCases] Total oracle records where their corresponding case is closed and their counter will be increased ${closedCasesEntries.length}`, + this.getLogMetadata(params, { tags: ['case-connector:createNewCasesOutOfClosedCases'] }) + ); + + const bulkUpdateOracleValidRecords = await this.increaseOracleRecordCounter( + params, + closedCasesEntries.map((entry) => entry.oracleRecord) + ); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][createNewCasesOutOfClosedCases] Total oracle records where their corresponding case is closed and their counter got increased ${bulkUpdateOracleValidRecords.length}`, + this.getLogMetadata(params, { + tags: [ + 'case-connector:createNewCasesOutOfClosedCases', + ...closedCasesEntries.map(({ oracleKey }) => oracleKey), + ], + }) + ); + + const groupedAlertsWithOracleRecords = new Map(); + + for (const record of bulkUpdateOracleValidRecords) { + const foundRecord = findEntryByOracleRecord(record.id); + + if (foundRecord) { + groupedAlertsWithOracleRecords.set(record.id, { + oracleKey: record.id, + oracleRecord: foundRecord.oracleRecord, + alerts: foundRecord.alerts, + grouping: foundRecord.grouping, + }); + } + } + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][createNewCasesOutOfClosedCases] Generating ${groupedAlertsWithOracleRecords.size} case IDs`, + this.getLogMetadata(params, { tags: ['case-connector:createNewCasesOutOfClosedCases'] }) + ); + + const groupedAlertsWithCaseId = this.generateCaseIds(params, groupedAlertsWithOracleRecords); + + const customFieldsConfigurationMap = await this.getCustomFieldsConfiguration(); + + const bulkCreateReq = Array.from(groupedAlertsWithCaseId.values()).map((record) => + this.getCreateCaseRequest(params, record, customFieldsConfigurationMap.get(params.owner)) + ); + + const idsToCreate = bulkCreateReq.map(({ id }) => id); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][createNewCasesOutOfClosedCases] Creating cases with ids ${idsToCreate}`, + this.getLogMetadata(params, { + tags: ['case-connector:createNewCasesOutOfClosedCases', ...idsToCreate], + }) + ); + + /** + * cases.bulkCreate throws an error on errors + */ + const bulkCreateCasesResponse = await this.casesClient.cases.bulkCreate({ + cases: bulkCreateReq, + }); + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][createNewCasesOutOfClosedCases] The total number of created cases is ${bulkCreateCasesResponse.cases.length}`, + this.getLogMetadata(params, { + labels: { + total: bulkCreateCasesResponse.cases.length, + }, + tags: ['case-connector:createNewCasesOutOfClosedCases'], + }) + ); + + for (const theCase of bulkCreateCasesResponse.cases) { + if (groupedAlertsWithCaseId.has(theCase.id)) { + const data = groupedAlertsWithCaseId.get(theCase.id) as GroupedAlertsWithCaseId; + casesMapWithNewCases.set(theCase.id, { ...data, theCase }); + } + } + + return casesMapWithNewCases; + } + + private async attachAlertsToCases( + groupedAlertsWithCases: Map, + params: CasesConnectorRunParams + ): Promise { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][attachAlertsToCases] Attaching alerts to ${groupedAlertsWithCases.size} cases`, + this.getLogMetadata(params, { tags: ['case-connector:attachAlertsToCases'] }) + ); + + const { rule } = params; + + const [casesUnderAlertLimit, casesOverAlertLimit] = partition( + Array.from(groupedAlertsWithCases.values()), + ({ theCase, alerts }) => theCase.totalAlerts + alerts.length <= MAX_ALERTS_PER_CASE + ); + + if (casesOverAlertLimit.length > 0) { + const ids = casesOverAlertLimit.map(({ theCase }) => theCase.id); + const totalAlerts = casesOverAlertLimit.map(({ alerts }) => alerts.length).flat().length; + + this.logger.warn( + `Cases with ids "${ids.join( + ',' + )}" contain more than ${MAX_ALERTS_PER_CASE} alerts. The new alerts will not be attached to the cases. Total new alerts: ${totalAlerts}`, + this.getLogMetadata(params) + ); + } + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][attachAlertsToCases] Attaching alerts to ${casesUnderAlertLimit.length} cases that do not have reach the alert limit per case`, + this.getLogMetadata(params, { + tags: [ + 'case-connector:attachAlertsToCases', + ...casesUnderAlertLimit.map(({ caseId }) => caseId), + ], + }) + ); + + const bulkCreateAlertsRequest: BulkCreateAlertsReq[] = casesUnderAlertLimit.map( + ({ theCase, alerts }) => ({ + caseId: theCase.id, + attachments: [ + { + type: AttachmentType.alert, + rule: { id: rule.id, name: rule.name }, + /** + * Map traverses the array in ascending order. + * The order is guaranteed to be the same for + * both calls by the ECMA-262 spec. + */ + alertId: alerts.map((alert) => alert._id), + index: alerts.map((alert) => alert._index), + owner: theCase.owner, + }, + ], + }) + ); + + await pMap( + bulkCreateAlertsRequest, + /** + * attachments.bulkCreate throws an error on errors + */ + async (req: BulkCreateAlertsReq) => { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][attachAlertsToCases] Attaching ${req.attachments.length} alerts to case with ID ${req.caseId}`, + this.getLogMetadata(params, { + labels: { caseId: req.caseId }, + tags: [ + 'case-connector:attachAlertsToCases', + req.caseId, + ...(req.attachments as Array<{ alertId: string }>).map(({ alertId }) => alertId), + ], + }) + ); + + await this.casesClient.attachments.bulkCreate(req); + }, + { + concurrency: MAX_CONCURRENT_ES_REQUEST, + } + ); + } + + private handleAndThrowErrors(errors: SavedObjectError[]) { + if (errors.length === 0) { + return; + } + + const firstError = errors[0]; + const message = `${firstError.error}: ${firstError.message}`; + + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][handleAndThrowErrors] Error message "${message}"`, + { + tags: ['case-connector:handleAndThrowErrors'], + error: { code: firstError.statusCode.toString() }, + } + ); + + throw new CasesConnectorError(message, firstError.statusCode); + } + + private getLogMetadata( + params: CasesConnectorRunParams, + { tags = [], labels = {} }: { tags?: string[]; labels?: Record } = {} + ) { + return { tags: ['cases-connector', `rule:${params.rule.id}`, ...tags], labels }; + } + + private async getCustomFieldsConfiguration(): Promise> { + this.logger.debug( + `[CasesConnector][CasesConnectorExecutor][getCustomFieldsConfiguration] Getting case configurations`, + { tags: ['case-connector:getCustomFieldsConfiguration'] } + ); + const configurations = await this.casesClient.configure.get(); + return new Map(configurations.map((conf) => [conf.owner, conf.customFields])); + } +} diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.test.ts b/x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.test.ts new file mode 100644 index 0000000000000..ea64b20f2c1a2 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.test.ts @@ -0,0 +1,543 @@ +/* + * 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 { createHash } from 'node:crypto'; +import stringify from 'json-stable-stringify'; +import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; + +import { CasesOracleService } from './cases_oracle_service'; +import { CASE_RULES_SAVED_OBJECT } from '../../../common/constants'; +import { isEmpty, set } from 'lodash'; + +describe('CasesOracleService', () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const logger = loggingSystemMock.createLogger(); + + let service: CasesOracleService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new CasesOracleService({ savedObjectsClient, logger }); + }); + + describe('getRecordId', () => { + it('return the record ID correctly', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const grouping = { 'host.ip': '0.0.0.1' }; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getRecordId({ ruleId, spaceId, owner, grouping })).toEqual(hex); + }); + + it('sorts the grouping definition correctly', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }; + const sortedGrouping = { 'agent.id': '8a4f500d', 'host.ip': '0.0.0.1' }; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(sortedGrouping)}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getRecordId({ ruleId, spaceId, owner, grouping })).toEqual(hex); + }); + + it('return the record ID correctly without grouping', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + + const payload = `${ruleId}:${spaceId}:${owner}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getRecordId({ ruleId, spaceId, owner })).toEqual(hex); + }); + + it('return the record ID correctly with empty grouping', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const grouping = {}; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getRecordId({ ruleId, spaceId, owner, grouping })).toEqual(hex); + }); + + it('return the record ID correctly without rule', async () => { + const spaceId = 'default'; + const owner = 'cases'; + const grouping = { 'host.ip': '0.0.0.1' }; + + const payload = `${spaceId}:${owner}:${stringify(grouping)}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getRecordId({ spaceId, owner, grouping })).toEqual(hex); + }); + + it('throws an error when the ruleId and the grouping is missing', async () => { + const spaceId = 'default'; + const owner = 'cases'; + + // @ts-expect-error: ruleId and grouping are omitted for testing + expect(() => service.getRecordId({ spaceId, owner })).toThrowErrorMatchingInlineSnapshot( + `"ruleID or grouping is required"` + ); + }); + + it.each(['ruleId', 'spaceId', 'owner'])( + 'return the record ID correctly with empty string for %s', + async (key) => { + const getPayloadValue = (value: string) => (isEmpty(value) ? '' : `${value}:`); + + const params = { + ruleId: 'test-rule-id', + spaceId: 'default', + owner: 'cases', + }; + + const grouping = { 'host.ip': '0.0.0.1' }; + + set(params, key, ''); + + const payload = `${getPayloadValue(params.ruleId)}${getPayloadValue( + params.spaceId + )}${getPayloadValue(params.owner)}${stringify(grouping)}`; + + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getRecordId({ ...params, grouping })).toEqual(hex); + } + ); + + it('constructs a record ID with special characters correctly', async () => { + const ruleId = `{}=:&".'/{}}`; + const spaceId = 'default:'; + const owner = 'cases{'; + const grouping = { '{:}': `{}=:&".'/{}}` }; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getRecordId({ ruleId, spaceId, owner, grouping })).toEqual(hex); + }); + }); + + describe('getRecord', () => { + const rules = [{ id: 'test-rule-id' }]; + const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }; + + const oracleSO = { + id: 'so-id', + version: 'so-version', + attributes: { + counter: 1, + rules, + grouping, + createdAt: '2023-10-10T10:23:42.769Z', + updatedAt: '2023-10-10T10:23:42.769Z', + }, + type: CASE_RULES_SAVED_OBJECT, + references: [], + }; + + beforeEach(() => { + savedObjectsClient.get.mockResolvedValue(oracleSO); + }); + + it('gets a record correctly', async () => { + const record = await service.getRecord('so-id'); + + expect(record).toEqual({ ...oracleSO.attributes, id: 'so-id', version: 'so-version' }); + }); + + it('calls the savedObjectsClient.get method correctly', async () => { + await service.getRecord('so-id'); + + expect(savedObjectsClient.get).toHaveBeenCalledWith('cases-rules', 'so-id'); + }); + }); + + describe('bulkGetRecord', () => { + const rules = [{ id: 'test-rule-id' }]; + const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }; + + const bulkGetSOs = [ + { + id: 'so-id', + version: 'so-version', + attributes: { + counter: 1, + rules, + grouping, + createdAt: '2023-10-10T10:23:42.769Z', + updatedAt: '2023-10-10T10:23:42.769Z', + }, + type: CASE_RULES_SAVED_OBJECT, + references: [], + }, + { + id: 'so-id-2', + type: CASE_RULES_SAVED_OBJECT, + error: { + message: 'Not found', + statusCode: 404, + error: 'Not found', + }, + }, + ]; + + beforeEach(() => { + // @ts-expect-error: types of the SO client are wrong and they do not accept errors + savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: bulkGetSOs }); + }); + + it('formats the response correctly', async () => { + const res = await service.bulkGetRecords(['so-id', 'so-id-2']); + + expect(res).toEqual([ + { ...bulkGetSOs[0].attributes, id: 'so-id', version: 'so-version' }, + { ...bulkGetSOs[1].error, id: 'so-id-2' }, + ]); + }); + + it('calls the savedObjectsClient.bulkGet method correctly', async () => { + await service.bulkGetRecords(['so-id', 'so-id-2']); + + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith([ + { id: 'so-id', type: 'cases-rules' }, + { id: 'so-id-2', type: 'cases-rules' }, + ]); + }); + + it('does not call the savedObjectsClient if the input is an empty array', async () => { + await service.bulkGetRecords([]); + + expect(savedObjectsClient.bulkGet).not.toHaveBeenCalledWith(); + }); + }); + + describe('createRecord', () => { + const rules = [{ id: 'test-rule-id' }]; + const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }; + + const oracleSO = { + id: 'so-id', + version: 'so-version', + attributes: { + counter: 1, + rules, + grouping, + createdAt: '2023-10-10T10:23:42.769Z', + updatedAt: '2023-10-10T10:23:42.769Z', + }, + type: CASE_RULES_SAVED_OBJECT, + references: [], + }; + + beforeEach(() => { + savedObjectsClient.create.mockResolvedValue(oracleSO); + }); + + it('creates a record correctly', async () => { + const record = await service.createRecord('so-id', { rules, grouping }); + + expect(record).toEqual({ ...oracleSO.attributes, id: 'so-id', version: 'so-version' }); + }); + + it('calls the savedObjectsClient.create method correctly', async () => { + const id = 'so-id'; + + await service.createRecord(id, { rules, grouping }); + + expect(savedObjectsClient.create).toHaveBeenCalledWith( + 'cases-rules', + { + counter: 1, + createdAt: expect.anything(), + rules, + grouping, + updatedAt: null, + }, + { + id, + references: [ + { + id: 'test-rule-id', + name: 'associated-alert', + type: 'alert', + }, + ], + } + ); + }); + }); + + describe('bulkCreateRecord', () => { + const rules = [{ id: 'test-rule-id' }]; + const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }; + + const bulkCreateSOs = [ + { + id: 'so-id', + version: 'so-version', + attributes: { + counter: 1, + rules, + grouping, + createdAt: '2023-10-10T10:23:42.769Z', + updatedAt: '2023-10-10T10:23:42.769Z', + }, + type: CASE_RULES_SAVED_OBJECT, + references: [], + }, + { + id: 'so-id-2', + type: CASE_RULES_SAVED_OBJECT, + error: { + message: 'Not found', + statusCode: 404, + error: 'Not found', + }, + }, + ]; + + beforeEach(() => { + // @ts-expect-error: types of the SO client are wrong and they do not accept errors + savedObjectsClient.bulkCreate.mockResolvedValue({ saved_objects: bulkCreateSOs }); + }); + + it('formats the response correctly', async () => { + const res = await service.bulkCreateRecord([ + { recordId: 'so-id', payload: { rules, grouping } }, + { recordId: 'so-id-2', payload: { rules, grouping } }, + ]); + + expect(res).toEqual([ + { ...bulkCreateSOs[0].attributes, id: 'so-id', version: 'so-version' }, + { ...bulkCreateSOs[1].error, id: 'so-id-2' }, + ]); + }); + + it('calls the bulkCreate correctly', async () => { + await service.bulkCreateRecord([ + { recordId: 'so-id', payload: { rules, grouping } }, + { recordId: 'so-id-2', payload: { rules, grouping } }, + ]); + + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledWith([ + { + attributes: { + rules, + grouping, + counter: 1, + createdAt: expect.anything(), + updatedAt: null, + }, + id: 'so-id', + type: 'cases-rules', + references: [ + { + id: 'test-rule-id', + name: 'associated-alert', + type: 'alert', + }, + ], + }, + { + attributes: { + rules, + grouping, + counter: 1, + createdAt: expect.anything(), + updatedAt: null, + }, + id: 'so-id-2', + type: 'cases-rules', + references: [ + { + id: 'test-rule-id', + name: 'associated-alert', + type: 'alert', + }, + ], + }, + ]); + }); + + it('does not call the savedObjectsClient if the input is an empty array', async () => { + await service.bulkCreateRecord([]); + + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalledWith(); + }); + }); + + describe('increaseCounter', () => { + const rules = [{ id: 'test-rule-id' }]; + const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }; + + const oracleSO = { + id: 'so-id', + version: 'so-version', + attributes: { + counter: 1, + rules, + grouping, + createdAt: '2023-10-10T10:23:42.769Z', + updatedAt: '2023-10-10T10:23:42.769Z', + }, + type: CASE_RULES_SAVED_OBJECT, + references: [], + }; + + const oracleSOWithIncreasedCounter = { + ...oracleSO, + attributes: { ...oracleSO.attributes, counter: 2 }, + }; + + beforeEach(() => { + savedObjectsClient.get.mockResolvedValue(oracleSO); + savedObjectsClient.update.mockResolvedValue(oracleSOWithIncreasedCounter); + }); + + it('increases the counter correctly', async () => { + const record = await service.increaseCounter('so-id'); + + expect(record).toEqual({ + ...oracleSO.attributes, + id: 'so-id', + version: 'so-version', + counter: 2, + }); + }); + + it('calls the savedObjectsClient.update method correctly', async () => { + await service.increaseCounter('so-id'); + + expect(savedObjectsClient.update).toHaveBeenCalledWith( + 'cases-rules', + 'so-id', + { + counter: 2, + }, + { version: 'so-version' } + ); + }); + }); + + describe('bulkUpdateRecord', () => { + const bulkUpdateSOs = [ + { + id: 'so-id', + version: 'so-version', + attributes: { + counter: 1, + rules: [], + grouping: {}, + createdAt: '2023-10-10T10:23:42.769Z', + updatedAt: '2023-10-10T10:23:42.769Z', + }, + type: CASE_RULES_SAVED_OBJECT, + references: [], + }, + { + id: 'so-id-2', + type: CASE_RULES_SAVED_OBJECT, + error: { + message: 'Conflict', + statusCode: 409, + error: 'Conflict', + }, + }, + ]; + + beforeEach(() => { + // @ts-expect-error: types of the SO client are wrong and they do not accept errors + savedObjectsClient.bulkUpdate.mockResolvedValue({ saved_objects: bulkUpdateSOs }); + }); + + it('formats the response correctly', async () => { + const res = await service.bulkUpdateRecord([ + { recordId: 'so-id', version: 'so-version-1', payload: { counter: 2 } }, + { recordId: 'so-id-2', version: 'so-version-22', payload: { counter: 3 } }, + ]); + + expect(res).toEqual([ + { ...bulkUpdateSOs[0].attributes, id: 'so-id', version: 'so-version' }, + { ...bulkUpdateSOs[1].error, id: 'so-id-2' }, + ]); + }); + + it('calls the bulkUpdateRecord correctly', async () => { + await service.bulkUpdateRecord([ + { recordId: 'so-id', version: 'so-version-1', payload: { counter: 2 } }, + { recordId: 'so-id-2', version: 'so-version-2', payload: { counter: 3 } }, + ]); + + expect(savedObjectsClient.bulkUpdate).toHaveBeenCalledWith([ + { + attributes: { + counter: 2, + updatedAt: expect.anything(), + }, + id: 'so-id', + version: 'so-version-1', + type: 'cases-rules', + }, + { + attributes: { + counter: 3, + updatedAt: expect.anything(), + }, + id: 'so-id-2', + version: 'so-version-2', + type: 'cases-rules', + }, + ]); + }); + + it('does not call the savedObjectsClient if the input is an empty array', async () => { + await service.bulkUpdateRecord([]); + + expect(savedObjectsClient.bulkUpdate).not.toHaveBeenCalledWith(); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.ts b/x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.ts new file mode 100644 index 0000000000000..32d8ca99539af --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.ts @@ -0,0 +1,259 @@ +/* + * 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 { RULE_SAVED_OBJECT_TYPE } from '@kbn/alerting-plugin/server'; +import type { + Logger, + SavedObject, + SavedObjectReference, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import { CASE_RULES_SAVED_OBJECT } from '../../../common/constants'; +import { isSODecoratedError, isSOError } from '../../common/error'; +import type { SavedObjectsBulkResponseWithErrors } from '../../common/types'; +import { INITIAL_ORACLE_RECORD_COUNTER } from './constants'; +import { CryptoService } from './crypto_service'; +import type { + BulkCreateOracleRecordRequest, + BulkGetOracleRecordsResponse, + BulkUpdateOracleRecordRequest, + OracleKey, + OracleRecord, + OracleRecordAttributes, + OracleRecordCreateRequest, + OracleRecordError, + OracleSOError, +} from './types'; + +export class CasesOracleService { + private readonly logger: Logger; + private readonly savedObjectsClient: SavedObjectsClientContract; + private cryptoService: CryptoService; + + constructor({ + logger, + savedObjectsClient, + }: { + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; + }) { + this.logger = logger; + this.savedObjectsClient = savedObjectsClient; + this.cryptoService = new CryptoService(); + } + + public getRecordId({ ruleId, spaceId, owner, grouping }: OracleKey): string { + if (grouping == null && ruleId == null) { + throw new Error('ruleID or grouping is required'); + } + + const payload = [ + ruleId, + spaceId, + owner, + this.cryptoService.stringifyDeterministically(grouping), + ] + .filter(Boolean) + .join(':'); + + return this.cryptoService.getHash(payload); + } + + public async getRecord(recordId: string): Promise { + this.logger.debug(`Getting oracle record with ID: ${recordId}`, { + tags: ['cases-oracle-service', 'getRecord', recordId], + }); + + const oracleRecord = await this.savedObjectsClient.get( + CASE_RULES_SAVED_OBJECT, + recordId + ); + + return this.getRecordResponse(oracleRecord); + } + + public async bulkGetRecords(ids: string[]): Promise { + this.logger.debug(`Getting oracle records with IDs: ${ids}`, { + tags: ['cases-oracle-service', 'bulkGetRecords', ...ids], + }); + + if (ids.length === 0) { + return []; + } + + const oracleRecords = (await this.savedObjectsClient.bulkGet( + ids.map((id) => ({ id, type: CASE_RULES_SAVED_OBJECT })) + )) as SavedObjectsBulkResponseWithErrors; + + return this.getBulkRecordsResponse(oracleRecords); + } + + public async createRecord( + recordId: string, + payload: OracleRecordCreateRequest + ): Promise { + this.logger.debug(`Creating oracle record with ID: ${recordId}`, { + tags: ['cases-oracle-service', 'createRecord', recordId], + }); + + const oracleRecord = await this.savedObjectsClient.create( + CASE_RULES_SAVED_OBJECT, + this.getCreateRecordAttributes(payload), + { id: recordId, references: this.getCreateRecordReferences(payload) } + ); + + return this.getRecordResponse(oracleRecord); + } + + public async bulkCreateRecord( + records: BulkCreateOracleRecordRequest + ): Promise { + const recordIds = records.map((record) => record.recordId); + + this.logger.debug(`Creating oracle record with ID: ${recordIds}`, { + tags: ['cases-oracle-service', 'bulkCreateRecord', ...recordIds], + }); + + if (records.length === 0) { + return []; + } + + const req = records.map((record) => ({ + id: record.recordId, + type: CASE_RULES_SAVED_OBJECT, + attributes: this.getCreateRecordAttributes(record.payload), + references: this.getCreateRecordReferences(record.payload), + })); + + const oracleRecords = (await this.savedObjectsClient.bulkCreate( + req + )) as SavedObjectsBulkResponseWithErrors; + + return this.getBulkRecordsResponse(oracleRecords); + } + + public async increaseCounter(recordId: string): Promise { + const { id: _, version, ...record } = await this.getRecord(recordId); + const newCounter = record.counter + 1; + + this.logger.debug( + `Increasing the counter of oracle record with ID: ${recordId} from ${record.counter} to ${newCounter}`, + { + tags: ['cases-oracle-service', 'increaseCounter', recordId], + } + ); + + const oracleRecord = await this.savedObjectsClient.update( + CASE_RULES_SAVED_OBJECT, + recordId, + { counter: newCounter }, + { version } + ); + + return this.getRecordResponse({ + ...oracleRecord, + attributes: { ...record, counter: newCounter }, + references: oracleRecord.references ?? [], + }); + } + + public async bulkUpdateRecord( + records: BulkUpdateOracleRecordRequest + ): Promise { + const recordIds = records.map((record) => record.recordId); + + this.logger.debug(`Updating oracle record with ID: ${recordIds}`, { + tags: ['cases-oracle-service', 'bulkUpdateRecord', ...recordIds], + }); + + if (records.length === 0) { + return []; + } + + const req = records.map((record) => ({ + id: record.recordId, + type: CASE_RULES_SAVED_OBJECT, + version: record.version, + attributes: { ...record.payload, updatedAt: new Date().toISOString() }, + })); + + const oracleRecords = (await this.savedObjectsClient.bulkUpdate( + req + )) as SavedObjectsBulkResponseWithErrors; + + return this.getBulkRecordsResponse(oracleRecords); + } + + private getRecordResponse = ( + oracleRecord: SavedObject + ): OracleRecord => ({ + id: oracleRecord.id, + version: oracleRecord.version ?? '', + counter: oracleRecord.attributes.counter, + grouping: oracleRecord.attributes.grouping, + rules: oracleRecord.attributes.rules, + createdAt: oracleRecord.attributes.createdAt, + updatedAt: oracleRecord.attributes.updatedAt, + }); + + private getBulkRecordsResponse( + oracleRecords: SavedObjectsBulkResponseWithErrors + ): BulkGetOracleRecordsResponse { + return oracleRecords.saved_objects.map((oracleRecord) => { + if (isSOError(oracleRecord)) { + return this.getErrorResponse(oracleRecord.id, oracleRecord.error); + } + + return this.getRecordResponse(oracleRecord); + }); + } + + private getErrorResponse(id: string, error: OracleSOError): OracleRecordError { + if (isSODecoratedError(error)) { + return { + id, + error: error.output.payload.error, + message: error.output.payload.message, + statusCode: error.output.statusCode, + }; + } + + return { + id, + error: error.error, + message: error.message, + statusCode: error.statusCode, + }; + } + + private getCreateRecordAttributes({ rules, grouping }: OracleRecordCreateRequest) { + return { + counter: INITIAL_ORACLE_RECORD_COUNTER, + rules, + grouping, + createdAt: new Date().toISOString(), + updatedAt: null, + }; + } + + private getCreateRecordReferences({ + rules, + grouping, + }: OracleRecordCreateRequest): SavedObjectReference[] { + const references = []; + + for (const rule of rules) { + references.push({ + id: rule.id, + type: RULE_SAVED_OBJECT_TYPE, + name: `associated-${RULE_SAVED_OBJECT_TYPE}`, + }); + } + + return references; + } +} diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_service.test.ts b/x-pack/plugins/cases/server/connectors/cases/cases_service.test.ts new file mode 100644 index 0000000000000..848d3fa276236 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_service.test.ts @@ -0,0 +1,165 @@ +/* + * 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 { createHash } from 'node:crypto'; +import stringify from 'json-stable-stringify'; + +import { isEmpty, set } from 'lodash'; +import { CasesService } from './cases_service'; + +describe('CasesService', () => { + let service: CasesService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new CasesService(); + }); + + describe('getCaseId', () => { + it('return the record ID correctly', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const grouping = { 'host.ip': '0.0.0.1' }; + const counter = 1; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex); + }); + + it('sorts the grouping definition correctly', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }; + const sortedGrouping = { 'agent.id': '8a4f500d', 'host.ip': '0.0.0.1' }; + const counter = 1; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(sortedGrouping)}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex); + }); + + it('return the record ID correctly without grouping', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const counter = 1; + + const payload = `${ruleId}:${spaceId}:${owner}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ruleId, spaceId, owner, counter })).toEqual(hex); + }); + + it('return the record ID correctly with empty grouping', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const grouping = {}; + const counter = 1; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex); + }); + + it('return the record ID correctly without rule', async () => { + const spaceId = 'default'; + const owner = 'cases'; + const grouping = { 'host.ip': '0.0.0.1' }; + const counter = 1; + + const payload = `${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ spaceId, owner, grouping, counter })).toEqual(hex); + }); + + it('throws an error when the ruleId and the grouping is missing', async () => { + const spaceId = 'default'; + const owner = 'cases'; + const counter = 1; + + expect(() => + // @ts-expect-error: ruleId and grouping are omitted for testing + service.getCaseId({ spaceId, owner, counter }) + ).toThrowErrorMatchingInlineSnapshot(`"ruleID or grouping is required"`); + }); + + it.each(['ruleId', 'spaceId', 'owner'])( + 'return the record ID correctly with empty string for %s', + async (key) => { + const getPayloadValue = (value: string) => (isEmpty(value) ? '' : `${value}:`); + + const params = { + ruleId: 'test-rule-id', + spaceId: 'default', + owner: 'cases', + }; + + const grouping = { 'host.ip': '0.0.0.1' }; + const counter = 1; + + set(params, key, ''); + + const payload = `${getPayloadValue(params.ruleId)}${getPayloadValue( + params.spaceId + )}${getPayloadValue(params.owner)}${stringify(grouping)}:${counter}`; + + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ...params, grouping, counter })).toEqual(hex); + } + ); + + it('constructs a record ID with special characters correctly', async () => { + const ruleId = `{}=:&".'/{}}`; + const spaceId = 'default'; + const owner = 'cases'; + const grouping = { '{:}': `{}=:&".'/{}}` }; + const counter = 1; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_service.ts b/x-pack/plugins/cases/server/connectors/cases/cases_service.ts new file mode 100644 index 0000000000000..214049e5acc0c --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_service.ts @@ -0,0 +1,35 @@ +/* + * 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 { CryptoService } from './crypto_service'; +import type { CaseIdPayload } from './types'; + +export class CasesService { + private cryptoService: CryptoService; + + constructor() { + this.cryptoService = new CryptoService(); + } + + public getCaseId({ ruleId, spaceId, owner, grouping, counter }: CaseIdPayload): string { + if (grouping == null && ruleId == null) { + throw new Error('ruleID or grouping is required'); + } + + const payload = [ + ruleId, + spaceId, + owner, + this.cryptoService.stringifyDeterministically(grouping), + counter, + ] + .filter(Boolean) + .join(':'); + + return this.cryptoService.getHash(payload); + } +} diff --git a/x-pack/plugins/cases/server/connectors/cases/constants.ts b/x-pack/plugins/cases/server/connectors/cases/constants.ts new file mode 100644 index 0000000000000..b65668f3c7a6b --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/constants.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. + */ + +import { CustomFieldTypes } from '../../../common/types/domain'; + +export const MAX_CONCURRENT_ES_REQUEST = 5; +export const MAX_OPEN_CASES = 10; +export const DEFAULT_MAX_OPEN_CASES = 5; +export const INITIAL_ORACLE_RECORD_COUNTER = 1; + +export const VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS: Record = { + [CustomFieldTypes.TEXT]: 'N/A', + [CustomFieldTypes.TOGGLE]: false, +}; diff --git a/x-pack/plugins/cases/server/connectors/cases/crypto_service.test.ts b/x-pack/plugins/cases/server/connectors/cases/crypto_service.test.ts new file mode 100644 index 0000000000000..6ea5b32542ea5 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/crypto_service.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { createHash } from 'node:crypto'; +import { CryptoService } from './crypto_service'; + +describe('CryptoService', () => { + let service: CryptoService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new CryptoService(); + }); + + describe('getHash', () => { + it('returns the sha256 of a payload correctly', async () => { + const payload = 'my payload'; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getHash(payload)).toEqual(hex); + }); + + it('creates a new instance of the hash function on each call', async () => { + const payload = 'my payload'; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getHash(payload)).toEqual(hex); + expect(service.getHash(payload)).toEqual(hex); + }); + }); + + describe('stringifyDeterministically', () => { + it('deterministically stringifies an object', async () => { + expect( + service.stringifyDeterministically({ 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }) + ).toEqual('{"agent.id":"8a4f500d","host.ip":"0.0.0.1"}'); + }); + + it('returns null if the object is not defined', async () => { + expect(service.stringifyDeterministically()).toEqual(null); + }); + + it('handles special characters correctly', async () => { + expect(service.stringifyDeterministically({ [`{}=:&".'/{}}`]: `{}=:&".'{}}` })).toEqual( + `{\"{}=:&\\\".'/{}}\":\"{}=:&\\\".'{}}\"}` + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/crypto_service.ts b/x-pack/plugins/cases/server/connectors/cases/crypto_service.ts new file mode 100644 index 0000000000000..e35b4e51ed1b4 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/crypto_service.ts @@ -0,0 +1,26 @@ +/* + * 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 { createHash } from 'node:crypto'; +import stringify from 'json-stable-stringify'; + +export class CryptoService { + public getHash(payload: string): string { + const hash = createHash('sha256'); + + hash.update(payload); + return hash.digest('hex'); + } + + public stringifyDeterministically(obj?: Record): string | null { + if (obj == null) { + return null; + } + + return stringify(obj); + } +} diff --git a/x-pack/plugins/cases/server/connectors/cases/full_jitter_backoff.test.ts b/x-pack/plugins/cases/server/connectors/cases/full_jitter_backoff.test.ts new file mode 100644 index 0000000000000..2e6bc804558c7 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/full_jitter_backoff.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { fullJitterBackoffFactory } from './full_jitter_backoff'; + +describe('FullJitterBackoff', () => { + it('throws if the baseDelay is negative', async () => { + expect(() => + fullJitterBackoffFactory({ baseDelay: -1, maxBackoffTime: 2000 }).create() + ).toThrowErrorMatchingInlineSnapshot(`"baseDelay must not be negative"`); + }); + + it('throws if the maxBackoffTime is negative', async () => { + expect(() => + fullJitterBackoffFactory({ baseDelay: 5, maxBackoffTime: -1 }).create() + ).toThrowErrorMatchingInlineSnapshot(`"maxBackoffTime must not be negative"`); + }); + + it('starts with minimum of 1ms', () => { + const backoff = fullJitterBackoffFactory({ baseDelay: 0, maxBackoffTime: 4 }).create(); + expect(backoff.nextBackOff()).toBeGreaterThanOrEqual(1); + }); + + it('caps based on the maxBackoffTime', () => { + const maxBackoffTime = 4; + + const backoff = fullJitterBackoffFactory({ baseDelay: 1, maxBackoffTime }).create(); + + for (const _ of Array.from({ length: 1000 })) { + // maxBackoffTime plus the minimum 1ms + expect(backoff.nextBackOff()).toBeLessThanOrEqual(maxBackoffTime + 1); + } + }); + + it('caps retries', () => { + // 2^53 − 1 + const maxBackoffTime = Number.MAX_SAFE_INTEGER; + // The ceiling for the tries is 2^32 + const expectedCappedBackOff = Math.pow(2, 32); + + const backoff = fullJitterBackoffFactory({ baseDelay: 1, maxBackoffTime }).create(); + + for (const _ of Array.from({ length: 1000 })) { + // maxBackoffTime plus the minimum 1ms + expect(backoff.nextBackOff()).toBeLessThanOrEqual(expectedCappedBackOff + 1); + } + }); + + it('returns a random number between the expected range correctly', () => { + const baseDelay = 5; + const maxBackoffTime = 2000; + // 2^11 = 4096 > maxBackoffTime + const totalTries = 12; + + const backoff = fullJitterBackoffFactory({ baseDelay, maxBackoffTime }).create(); + + for (const index of Array.from(Array(totalTries).keys())) { + const maxExpectedRange = Math.min(maxBackoffTime, baseDelay * Math.pow(2, index)); + const nextBackOff = backoff.nextBackOff(); + + expect(nextBackOff).toBeGreaterThanOrEqual(1); + expect(nextBackOff).toBeLessThanOrEqual(maxExpectedRange + 1); + } + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/full_jitter_backoff.ts b/x-pack/plugins/cases/server/connectors/cases/full_jitter_backoff.ts new file mode 100644 index 0000000000000..9af1363c24945 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/full_jitter_backoff.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 type { BackoffFactory } from './types'; + +/** + * Implements the [Full Jitter Backoff algorithm]( + * https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) + * + */ + +/** + * To prevent from overflows we cap the maximum number of retries. + * There must be 2 ^ currentTry <= 2 ^ 53 - 1. + * We cap it to 2 ^ 32. + */ +const CURRENT_TRY_CEILING = 32; + +const getRandomIntegerFromInterval = (min: number, max: number) => { + return Math.floor(Math.random() * (max - min + 1) + min); +}; + +const throwIfNegative = (value: number, fieldName: string) => { + if (value < 0) { + throw new Error(`${fieldName} must not be negative`); + } +}; + +// Times are in ms +export const fullJitterBackoffFactory = ({ + baseDelay, + maxBackoffTime, +}: { + baseDelay: number; + maxBackoffTime: number; +}): BackoffFactory => { + throwIfNegative(baseDelay, 'baseDelay'); + throwIfNegative(maxBackoffTime, 'maxBackoffTime'); + + return { + create: () => { + let currentTry = 0; + return { + nextBackOff: () => { + const cappedCurrentTry = Math.min(CURRENT_TRY_CEILING, currentTry); + const sleep = Math.min(maxBackoffTime, baseDelay * Math.pow(2, cappedCurrentTry)); + + currentTry += 1; + + // Minimum of 1 ms + return getRandomIntegerFromInterval(0, sleep) + 1; + }, + }; + }, + }; +}; diff --git a/x-pack/plugins/cases/server/connectors/cases/index.mock.ts b/x-pack/plugins/cases/server/connectors/cases/index.mock.ts new file mode 100644 index 0000000000000..ef474d5d22aa2 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/index.mock.ts @@ -0,0 +1,176 @@ +/* + * 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 { Cases } from '../../../common'; +import { CASE_RULES_SAVED_OBJECT } from '../../../common/constants'; +import { mockCases } from '../../mocks'; +import type { OracleRecord, OracleRecordError } from './types'; + +export const oracleRecord: OracleRecord = { + id: 'so-id', + version: 'so-version', + rules: [{ id: 'test-rule-id' }], + grouping: { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }, + counter: 1, + createdAt: '2023-10-10T10:23:42.769Z', + updatedAt: '2023-10-10T10:23:42.769Z', +}; + +export const oracleRecordError: OracleRecordError = { + id: 'so-id', + error: 'An error', + statusCode: 404, + message: 'An error', +}; + +export const alerts = [ + { + _id: 'alert-id-0', + _index: 'alert-index-0', + 'host.name': 'A', + 'dest.ip': '0.0.0.1', + 'source.ip': '0.0.0.2', + }, + { + _id: 'alert-id-1', + _index: 'alert-index-1', + 'host.name': 'B', + 'dest.ip': '0.0.0.1', + 'file.hash': '12345', + }, + { _id: 'alert-id-2', _index: 'alert-index-2', 'host.name': 'A', 'dest.ip': '0.0.0.1' }, + { _id: 'alert-id-3', _index: 'alert-index-3', 'host.name': 'B', 'dest.ip': '0.0.0.3' }, +]; + +export const alertsNested = [ + { + _id: 'alert-id-0', + _index: 'alert-index-0', + host: { name: 'A' }, + dest: { ip: '0.0.0.1' }, + source: { ip: '0.0.0.2' }, + }, + { + _id: 'alert-id-1', + _index: 'alert-index-1', + host: { name: 'B' }, + dest: { ip: '0.0.0.1' }, + file: { hash: '12345' }, + }, + { + _id: 'alert-id-2', + _index: 'alert-index-2', + host: { name: 'A' }, + dest: { ip: '0.0.0.1' }, + }, + { + _id: 'alert-id-3', + _index: 'alert-index-3', + host: { name: 'B' }, + dest: { ip: '0.0.0.3' }, + }, + { + _id: 'alert-id-4', + _index: 'alert-index-4', + host: { name: 'A' }, + source: { ip: '0.0.0.5' }, + }, +]; + +export const alertsWithNoGrouping = [ + ...alerts, + { _id: 'alert-id-4', _index: 'alert-index-4', 'host.name': 'A', 'source.ip': '0.0.0.5' }, + { _id: 'alert-id-5', _index: 'alert-index-5' }, +]; + +export const groupingBy = ['host.name', 'dest.ip']; +export const rule = { + id: 'rule-test-id', + name: 'Test rule', + tags: ['rule', 'test'], + ruleUrl: 'https://example.com/rules/rule-test-id', +}; + +export const owner = 'cases'; +export const timeWindow = '7d'; +export const reopenClosedCases = false; + +export const groupedAlertsWithOracleKey = [ + { + alerts: [alerts[0], alerts[2]], + grouping: { 'host.name': 'A', 'dest.ip': '0.0.0.1' }, + oracleKey: 'so-oracle-record-0', + }, + { + alerts: [alerts[1]], + grouping: { 'host.name': 'B', 'dest.ip': '0.0.0.1' }, + oracleKey: 'so-oracle-record-1', + }, + { + alerts: [alerts[3]], + grouping: { 'host.name': 'B', 'dest.ip': '0.0.0.3' }, + oracleKey: 'so-oracle-record-2', + }, +]; + +export const oracleRecords = [ + { + id: groupedAlertsWithOracleKey[0].oracleKey, + version: 'so-version-0', + counter: 1, + cases: [], + rules: [], + grouping: groupedAlertsWithOracleKey[0].grouping, + createdAt: '2023-10-10T10:23:42.769Z', + updatedAt: '2023-10-10T10:23:42.769Z', + }, + { + id: groupedAlertsWithOracleKey[1].oracleKey, + version: 'so-version-1', + counter: 1, + cases: [], + rules: [], + grouping: groupedAlertsWithOracleKey[1].grouping, + createdAt: '2023-10-12T10:23:42.769Z', + updatedAt: '2023-10-12T10:23:42.769Z', + }, + { + id: groupedAlertsWithOracleKey[2].oracleKey, + type: CASE_RULES_SAVED_OBJECT, + message: 'Not found', + statusCode: 404, + error: 'Not found', + }, +]; + +export const createdOracleRecord = { + ...oracleRecords[0], + id: groupedAlertsWithOracleKey[2].oracleKey, + grouping: groupedAlertsWithOracleKey[2].grouping, + version: 'so-version-2', + createdAt: '2023-11-13T10:23:42.769Z', + updatedAt: '2023-11-13T10:23:42.769Z', +}; + +export const updatedCounterOracleRecord = { + ...oracleRecords[0], + // another node increased the counter + counter: 2, + id: groupedAlertsWithOracleKey[0].oracleKey, + grouping: groupedAlertsWithOracleKey[0].grouping, + version: 'so-version-3', + createdAt: '2023-11-13T10:23:42.769Z', + updatedAt: '2023-11-13T10:23:42.769Z', +}; + +export const cases: Cases = mockCases.map((so) => ({ + ...so.attributes, + id: so.id, + version: so.version ?? '', + totalComment: 0, + totalAlerts: 0, +})); diff --git a/x-pack/plugins/cases/server/connectors/cases/index.test.ts b/x-pack/plugins/cases/server/connectors/cases/index.test.ts new file mode 100644 index 0000000000000..ee559b335ef20 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/index.test.ts @@ -0,0 +1,288 @@ +/* + * 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 { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import type { CasesConnectorConfig, CasesConnectorSecrets } from './types'; +import { getCasesConnectorAdapter, getCasesConnectorType } from '.'; +import { AlertConsumers } from '@kbn/rule-data-utils'; + +describe('getCasesConnectorType', () => { + let caseConnectorType: SubActionConnectorType; + + beforeEach(() => { + caseConnectorType = getCasesConnectorType({ + getCasesClient: jest.fn(), + getUnsecuredSavedObjectsClient: jest.fn(), + getSpaceId: jest.fn(), + }); + }); + + describe('getKibanaPrivileges', () => { + it('construct the kibana privileges correctly', () => { + expect( + caseConnectorType.getKibanaPrivileges?.({ + params: { subAction: 'run', subActionParams: { owner: 'my-owner' } }, + }) + ).toEqual([ + 'cases:my-owner/createCase', + 'cases:my-owner/updateCase', + 'cases:my-owner/deleteCase', + 'cases:my-owner/pushCase', + 'cases:my-owner/createComment', + 'cases:my-owner/updateComment', + 'cases:my-owner/deleteComment', + 'cases:my-owner/findConfigurations', + ]); + }); + + it('throws if the owner is undefined', () => { + expect(() => caseConnectorType.getKibanaPrivileges?.()).toThrowErrorMatchingInlineSnapshot( + `"Cannot authorize cases. Owner is not defined in the subActionParams."` + ); + }); + }); + + describe('getCasesConnectorAdapter', () => { + const alerts = { + all: { + data: [ + { _id: 'alert-id-1', _index: 'alert-index-1' }, + { _id: 'alert-id-2', _index: 'alert-index-2' }, + ], + count: 2, + }, + new: { data: [{ _id: 'alert-id-1', _index: 'alert-index-1' }], count: 1 }, + ongoing: { data: [{ _id: 'alert-id-2', _index: 'alert-index-2' }], count: 1 }, + recovered: { data: [], count: 0 }, + }; + + const rule = { + id: 'rule-id', + name: 'my rule name', + tags: ['my-tag'], + consumer: 'test-consumer', + }; + + const getParams = (overrides = {}) => ({ + subAction: 'run' as const, + subActionParams: { groupingBy: [], reopenClosedCases: false, timeWindow: '7d', ...overrides }, + }); + + it('sets the correct connectorTypeId', () => { + const adapter = getCasesConnectorAdapter(); + + expect(adapter.connectorTypeId).toEqual('.cases'); + }); + + describe('ruleActionParamsSchema', () => { + it('validates getParams() correctly', () => { + const adapter = getCasesConnectorAdapter(); + + expect(adapter.ruleActionParamsSchema.validate(getParams())).toEqual(getParams()); + }); + + it('throws if missing getParams()', () => { + const adapter = getCasesConnectorAdapter(); + + expect(() => adapter.ruleActionParamsSchema.validate({})).toThrow(); + }); + + it('does not accept more than one groupingBy key', () => { + const adapter = getCasesConnectorAdapter(); + + expect(() => + adapter.ruleActionParamsSchema.validate( + getParams({ groupingBy: ['host.name', 'source.ip'] }) + ) + ).toThrow(); + }); + + it('should fail with not valid time window', () => { + const adapter = getCasesConnectorAdapter(); + + expect(() => + adapter.ruleActionParamsSchema.validate(getParams({ timeWindow: '10d+3d' })) + ).toThrow(); + }); + }); + + describe('buildActionParams', () => { + it('builds the action getParams() correctly', () => { + const adapter = getCasesConnectorAdapter(); + + expect( + adapter.buildActionParams({ + // @ts-expect-error: not all fields are needed + alerts, + rule, + params: getParams(), + spaceId: 'default', + ruleUrl: 'https://example.com', + }) + ).toMatchInlineSnapshot(` + Object { + "subAction": "run", + "subActionParams": Object { + "alerts": Array [ + Object { + "_id": "alert-id-1", + "_index": "alert-index-1", + }, + Object { + "_id": "alert-id-2", + "_index": "alert-index-2", + }, + ], + "groupingBy": Array [], + "maximumCasesToOpen": 5, + "owner": "cases", + "reopenClosedCases": false, + "rule": Object { + "id": "rule-id", + "name": "my rule name", + "ruleUrl": "https://example.com", + "tags": Array [ + "my-tag", + ], + }, + "timeWindow": "7d", + }, + } + `); + }); + + it('builds the action getParams() correctly without ruleUrl', () => { + const adapter = getCasesConnectorAdapter(); + expect( + adapter.buildActionParams({ + // @ts-expect-error: not all fields are needed + alerts, + rule, + params: getParams(), + spaceId: 'default', + }) + ).toMatchInlineSnapshot(` + Object { + "subAction": "run", + "subActionParams": Object { + "alerts": Array [ + Object { + "_id": "alert-id-1", + "_index": "alert-index-1", + }, + Object { + "_id": "alert-id-2", + "_index": "alert-index-2", + }, + ], + "groupingBy": Array [], + "maximumCasesToOpen": 5, + "owner": "cases", + "reopenClosedCases": false, + "rule": Object { + "id": "rule-id", + "name": "my rule name", + "ruleUrl": null, + "tags": Array [ + "my-tag", + ], + }, + "timeWindow": "7d", + }, + } + `); + }); + + it('maps observability consumers to the correct owner', () => { + const adapter = getCasesConnectorAdapter(); + + for (const consumer of [ + AlertConsumers.OBSERVABILITY, + AlertConsumers.APM, + AlertConsumers.INFRASTRUCTURE, + AlertConsumers.LOGS, + AlertConsumers.SLO, + AlertConsumers.UPTIME, + AlertConsumers.MONITORING, + ]) { + const connectorParams = adapter.buildActionParams({ + // @ts-expect-error: not all fields are needed + alerts, + rule: { ...rule, consumer }, + params: getParams(), + spaceId: 'default', + }); + + expect(connectorParams.subActionParams.owner).toBe('observability'); + } + }); + + it('maps security solution consumers to the correct owner', () => { + const adapter = getCasesConnectorAdapter(); + + for (const consumer of [AlertConsumers.SIEM]) { + const connectorParams = adapter.buildActionParams({ + // @ts-expect-error: not all fields are needed + alerts, + rule: { ...rule, consumer }, + params: getParams(), + spaceId: 'default', + }); + + expect(connectorParams.subActionParams.owner).toBe('securitySolution'); + } + }); + + it('maps stack consumers to the correct owner', () => { + const adapter = getCasesConnectorAdapter(); + + for (const consumer of [AlertConsumers.ML, AlertConsumers.STACK_ALERTS]) { + const connectorParams = adapter.buildActionParams({ + // @ts-expect-error: not all fields are needed + alerts, + rule: { ...rule, consumer }, + params: getParams(), + spaceId: 'default', + }); + + expect(connectorParams.subActionParams.owner).toBe('cases'); + } + }); + + it('fallback to the cases owner if the consumer is not in the mapping', () => { + const adapter = getCasesConnectorAdapter(); + + const connectorParams = adapter.buildActionParams({ + // @ts-expect-error: not all fields are needed + alerts, + rule: { ...rule, consumer: 'not-valid' }, + params: getParams(), + spaceId: 'default', + }); + + expect(connectorParams.subActionParams.owner).toBe('cases'); + }); + }); + + describe('getKibanaPrivileges', () => { + it('constructs the correct privileges from the consumer', () => { + const adapter = getCasesConnectorAdapter(); + + expect(adapter.getKibanaPrivileges?.({ consumer: AlertConsumers.SIEM })).toEqual([ + 'cases:securitySolution/createCase', + 'cases:securitySolution/updateCase', + 'cases:securitySolution/deleteCase', + 'cases:securitySolution/pushCase', + 'cases:securitySolution/createComment', + 'cases:securitySolution/updateComment', + 'cases:securitySolution/deleteComment', + 'cases:securitySolution/findConfigurations', + ]); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/index.ts b/x-pack/plugins/cases/server/connectors/cases/index.ts new file mode 100644 index 0000000000000..bec6fdcb97b69 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/index.ts @@ -0,0 +1,116 @@ +/* + * 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 { AlertingConnectorFeatureId, UptimeConnectorFeatureId } from '@kbn/actions-plugin/common'; +import type { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { ConnectorAdapter } from '@kbn/alerting-plugin/server'; +import type { Owner } from '../../../common/constants/types'; +import { CasesConnector } from './cases_connector'; +import { DEFAULT_MAX_OPEN_CASES } from './constants'; +import { CASES_CONNECTOR_ID, CASES_CONNECTOR_TITLE, OWNER_INFO } from '../../../common/constants'; +import type { + CasesConnectorConfig, + CasesConnectorParams, + CasesConnectorRuleActionParams, + CasesConnectorSecrets, +} from './types'; +import { + CasesConnectorConfigSchema, + CasesConnectorRuleActionParamsSchema, + CasesConnectorSecretsSchema, +} from './schema'; +import type { CasesClient } from '../../client'; +import { constructRequiredKibanaPrivileges } from './utils'; + +interface GetCasesConnectorTypeArgs { + getCasesClient: (request: KibanaRequest) => Promise; + getUnsecuredSavedObjectsClient: ( + request: KibanaRequest, + savedObjectTypes: string[] + ) => Promise; + getSpaceId: (request?: KibanaRequest) => string; +} + +export const getCasesConnectorType = ({ + getCasesClient, + getSpaceId, + getUnsecuredSavedObjectsClient, +}: GetCasesConnectorTypeArgs): SubActionConnectorType< + CasesConnectorConfig, + CasesConnectorSecrets +> => ({ + id: CASES_CONNECTOR_ID, + name: CASES_CONNECTOR_TITLE, + getService: (params) => + new CasesConnector({ + casesParams: { getCasesClient, getSpaceId, getUnsecuredSavedObjectsClient }, + connectorParams: params, + }), + schema: { + config: CasesConnectorConfigSchema, + secrets: CasesConnectorSecretsSchema, + }, + supportedFeatureIds: [UptimeConnectorFeatureId, AlertingConnectorFeatureId], + minimumLicenseRequired: 'platinum' as const, + isSystemActionType: true, + getKibanaPrivileges: ({ params } = { params: { subAction: 'run', subActionParams: {} } }) => { + const owner = params?.subActionParams?.owner as string; + + if (!owner) { + throw new Error('Cannot authorize cases. Owner is not defined in the subActionParams.'); + } + + return constructRequiredKibanaPrivileges(owner); + }, +}); + +export const getCasesConnectorAdapter = (): ConnectorAdapter< + CasesConnectorRuleActionParams, + CasesConnectorParams +> => { + return { + connectorTypeId: CASES_CONNECTOR_ID, + ruleActionParamsSchema: CasesConnectorRuleActionParamsSchema, + buildActionParams: ({ alerts, rule, params, spaceId, ruleUrl }) => { + const caseAlerts = [...alerts.new.data, ...alerts.ongoing.data]; + + const owner = getOwnerFromRuleConsumer(rule.consumer); + + const subActionParams = { + alerts: caseAlerts, + rule: { id: rule.id, name: rule.name, tags: rule.tags, ruleUrl: ruleUrl ?? null }, + groupingBy: params.subActionParams.groupingBy, + owner, + reopenClosedCases: params.subActionParams.reopenClosedCases, + timeWindow: params.subActionParams.timeWindow, + maximumCasesToOpen: DEFAULT_MAX_OPEN_CASES, + }; + + return { subAction: 'run', subActionParams }; + }, + getKibanaPrivileges: ({ consumer }) => { + const owner = getOwnerFromRuleConsumer(consumer); + return constructRequiredKibanaPrivileges(owner); + }, + }; +}; + +const getOwnerFromRuleConsumer = (consumer: string): Owner => { + for (const value of Object.values(OWNER_INFO)) { + const foundedConsumer = value.validRuleConsumers?.find( + (validConsumer) => validConsumer === consumer + ); + + if (foundedConsumer) { + return value.id; + } + } + + return OWNER_INFO.cases.id; +}; diff --git a/x-pack/plugins/cases/server/connectors/cases/retry_service.test.ts b/x-pack/plugins/cases/server/connectors/cases/retry_service.test.ts new file mode 100644 index 0000000000000..c3a8d419d1557 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/retry_service.test.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core-logging-browser-mocks'; +import type { Logger } from '@kbn/core/server'; +import { CasesConnectorError } from './cases_connector_error'; +import { CaseConnectorRetryService } from './retry_service'; +import type { BackoffFactory } from './types'; + +describe('CryptoService', () => { + const nextBackOff = jest.fn(); + const cb = jest.fn(); + + const backOffFactory: BackoffFactory = { + create: () => ({ nextBackOff }), + }; + + const mockLogger = loggingSystemMock.create().get() as jest.Mocked; + + let service: CaseConnectorRetryService; + + beforeEach(() => { + jest.clearAllMocks(); + + nextBackOff.mockReturnValue(1); + service = new CaseConnectorRetryService(mockLogger, backOffFactory); + }); + + it('should not retry if the error is not CasesConnectorError', async () => { + cb.mockRejectedValue(new Error('My error')); + + await expect(() => service.retryWithBackoff(cb)).rejects.toThrowErrorMatchingInlineSnapshot( + `"My error"` + ); + + expect(cb).toBeCalledTimes(1); + expect(nextBackOff).not.toBeCalled(); + }); + + it('should not retry if the status code is not supported', async () => { + cb.mockRejectedValue(new CasesConnectorError('My case connector error', 500)); + + await expect(() => service.retryWithBackoff(cb)).rejects.toThrowErrorMatchingInlineSnapshot( + `"My case connector error"` + ); + + expect(cb).toBeCalledTimes(1); + expect(nextBackOff).not.toBeCalled(); + }); + + it('should not retry after trying more than the max attempts', async () => { + const maxAttempts = 3; + service = new CaseConnectorRetryService(mockLogger, backOffFactory, maxAttempts); + + cb.mockRejectedValue(new CasesConnectorError('My transient error', 409)); + + await expect(() => service.retryWithBackoff(cb)).rejects.toThrowErrorMatchingInlineSnapshot( + `"My transient error"` + ); + + expect(cb).toBeCalledTimes(maxAttempts + 1); + expect(nextBackOff).toBeCalledTimes(maxAttempts); + }); + + it.each([409, 429, 503])( + 'should retry and succeed retryable status code: %s', + async (statusCode) => { + const maxAttempts = 3; + service = new CaseConnectorRetryService(mockLogger, backOffFactory, maxAttempts); + + const error = new CasesConnectorError('My transient error', statusCode); + cb.mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValue({ status: 'ok' }); + + const res = await service.retryWithBackoff(cb); + + expect(nextBackOff).toBeCalledTimes(maxAttempts - 1); + expect(cb).toBeCalledTimes(maxAttempts); + expect(res).toEqual({ status: 'ok' }); + } + ); + + it('should succeed if cb does not throw', async () => { + service = new CaseConnectorRetryService(mockLogger, backOffFactory); + + cb.mockResolvedValue({ status: 'ok' }); + + const res = await service.retryWithBackoff(cb); + + expect(nextBackOff).toBeCalledTimes(0); + expect(cb).toBeCalledTimes(1); + expect(res).toEqual({ status: 'ok' }); + }); + + describe('Logging', () => { + it('should log a warning when retrying', async () => { + service = new CaseConnectorRetryService(mockLogger, backOffFactory, 2); + + cb.mockRejectedValue(new CasesConnectorError('My transient error', 409)); + + await expect(() => service.retryWithBackoff(cb)).rejects.toThrowErrorMatchingInlineSnapshot( + `"My transient error"` + ); + + expect(mockLogger.warn).toBeCalledTimes(2); + expect(mockLogger.warn).toHaveBeenNthCalledWith( + 1, + '[CaseConnector] Case connector failed with status code 409. Attempt for retry: 1' + ); + + expect(mockLogger.warn).toHaveBeenNthCalledWith( + 2, + '[CaseConnector] Case connector failed with status code 409. Attempt for retry: 2' + ); + }); + + it('should not log a warning when the error is not supported', async () => { + cb.mockRejectedValue(new Error('My error')); + + await expect(() => service.retryWithBackoff(cb)).rejects.toThrowErrorMatchingInlineSnapshot( + `"My error"` + ); + + expect(mockLogger.warn).not.toBeCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/retry_service.ts b/x-pack/plugins/cases/server/connectors/cases/retry_service.ts new file mode 100644 index 0000000000000..bffb5d10c5b9f --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/retry_service.ts @@ -0,0 +1,114 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { CasesConnectorError } from './cases_connector_error'; +import type { BackoffStrategy, BackoffFactory } from './types'; + +export class CaseConnectorRetryService { + private logger: Logger; + private maxAttempts: number; + /** + * 409 - Conflict + * 429 - Too Many Requests + * 503 - ES Unavailable + * + * Full list of errors: packages/core/saved-objects/core-saved-objects-server/src/saved_objects_error_helpers.ts + */ + private readonly RETRY_ERROR_STATUS_CODES: number[] = [409, 429, 503]; + private readonly backOffStrategy: BackoffStrategy; + + private timer: NodeJS.Timeout | null = null; + private attempt: number = 0; + + constructor(logger: Logger, backOffFactory: BackoffFactory, maxAttempts: number = 10) { + this.logger = logger; + this.backOffStrategy = backOffFactory.create(); + this.maxAttempts = maxAttempts; + } + + public async retryWithBackoff(cb: () => Promise): Promise { + try { + this.logger.debug( + `[CasesConnector][retryWithBackoff] Running case connector. Attempt: ${this.attempt}`, + { + labels: { attempt: this.attempt }, + tags: ['case-connector:retry-start'], + } + ); + + const res = await cb(); + + this.logger.debug( + `[CasesConnector][retryWithBackoff] Case connector run successfully after ${this.attempt} attempts`, + { + labels: { attempt: this.attempt }, + tags: ['case-connector:retry-success'], + } + ); + + return res; + } catch (error) { + if (this.shouldRetry() && this.isRetryableError(error)) { + this.stop(); + this.attempt++; + + await this.delay(); + + this.logger.warn( + `[CaseConnector] Case connector failed with status code ${error.statusCode}. Attempt for retry: ${this.attempt}` + ); + + return this.retryWithBackoff(cb); + } + + throw error; + } finally { + this.logger.debug( + `[CasesConnector][retryWithBackoff] Case connector run ended after ${this.attempt} attempts`, + { + labels: { attempt: this.attempt }, + tags: ['case-connector:retry-end'], + } + ); + } + } + + private shouldRetry() { + return this.attempt < this.maxAttempts; + } + + private isRetryableError(error: Error) { + if ( + error instanceof CasesConnectorError && + this.RETRY_ERROR_STATUS_CODES.includes(error.statusCode) + ) { + return true; + } + + this.logger.debug(`[CasesConnector][isRetryableError] Error is not retryable`, { + tags: ['case-connector:retry-error'], + }); + + return false; + } + + private async delay() { + const ms = this.backOffStrategy.nextBackOff(); + + return new Promise((resolve) => { + this.timer = setTimeout(resolve, ms); + }); + } + + private stop(): void { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } +} diff --git a/x-pack/plugins/cases/server/connectors/cases/schema.test.ts b/x-pack/plugins/cases/server/connectors/cases/schema.test.ts new file mode 100644 index 0000000000000..d07c42d2fd5bd --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/schema.test.ts @@ -0,0 +1,196 @@ +/* + * 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 { CasesConnectorRunParamsSchema } from './schema'; + +describe('CasesConnectorRunParamsSchema', () => { + const getParams = (overrides = {}) => ({ + alerts: [{ _id: 'alert-id', _index: 'alert-index' }], + groupingBy: ['host.name'], + rule: { id: 'rule-id', name: 'Test rule', tags: [], ruleUrl: 'https://example.com' }, + owner: 'cases', + ...overrides, + }); + + it('accepts valid params', () => { + expect(CasesConnectorRunParamsSchema.validate(getParams())).toMatchInlineSnapshot(` + Object { + "alerts": Array [ + Object { + "_id": "alert-id", + "_index": "alert-index", + }, + ], + "groupingBy": Array [ + "host.name", + ], + "maximumCasesToOpen": 5, + "owner": "cases", + "reopenClosedCases": false, + "rule": Object { + "id": "rule-id", + "name": "Test rule", + "ruleUrl": "https://example.com", + "tags": Array [], + }, + "timeWindow": "7d", + } + `); + }); + + describe('alerts', () => { + it('throws if the alerts do not contain _id and _index', () => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ alerts: [{ foo: 'bar' }] })) + ).toThrow(); + }); + }); + + describe('groupingBy', () => { + it('accept an empty groupingBy array', () => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ groupingBy: [] })) + ).not.toThrow(); + }); + + it('does not accept more than one groupingBy key', () => { + expect(() => + CasesConnectorRunParamsSchema.validate( + getParams({ groupingBy: ['host.name', 'source.ip'] }) + ) + ).toThrow(); + }); + }); + + describe('rule', () => { + it('accept empty tags', () => { + const params = getParams(); + + expect(() => + CasesConnectorRunParamsSchema.validate({ ...params, rule: { ...params.rule, tags: [] } }) + ).not.toThrow(); + }); + + it('does not accept an empty tag', () => { + const params = getParams(); + + expect(() => + CasesConnectorRunParamsSchema.validate({ + ...params, + rule: { ...params.rule, tags: '' }, + }) + ).toThrow(); + }); + }); + + describe('timeWindow', () => { + it('throws if the first digit starts with zero', () => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '0d' })) + ).toThrow(); + }); + + it('throws if the timeWindow does not start with a number', () => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: 'd1' })) + ).toThrow(); + }); + + it('should fail for valid date math but not valid time window', () => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '10d+3d' })) + ).toThrow(); + }); + + it('throws if there is a non valid letter at the end', () => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '10d#' })) + ).toThrow(); + }); + + it('throws if there is a valid letter at the end', () => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '10dd' })) + ).toThrow(); + }); + + it('throws if there is a digit at the end', () => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '10d2' })) + ).toThrow(); + }); + + it('throws if there are two valid formats in sequence', () => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '1d2d' })) + ).toThrow(); + }); + + it('accepts double digit numbers', () => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: '10d' })) + ).not.toThrow(); + }); + + it.each(['s', 'm', 'H', 'h', 'M', 'y'])('does not allow time unit %s', (unit) => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: `5${unit}` })) + ).toThrow(); + }); + + it.each(['d', 'w'])('allows time unit %s', (unit) => { + expect(() => + CasesConnectorRunParamsSchema.validate(getParams({ timeWindow: `5${unit}` })) + ).not.toThrow(); + }); + + it('defaults the timeWindow to 7d', () => { + expect(CasesConnectorRunParamsSchema.validate(getParams()).timeWindow).toBe('7d'); + }); + }); + + describe('reopenClosedCases', () => { + it('defaults the reopenClosedCases to false', () => { + expect(CasesConnectorRunParamsSchema.validate(getParams()).reopenClosedCases).toBe(false); + }); + }); + + describe('maximumCasesToOpen', () => { + it('defaults the maximumCasesToOpen to 5', () => { + expect(CasesConnectorRunParamsSchema.validate(getParams()).maximumCasesToOpen).toBe(5); + }); + + it('sets the maximumCasesToOpen correctly', () => { + expect( + CasesConnectorRunParamsSchema.validate(getParams({ maximumCasesToOpen: 3 })) + .maximumCasesToOpen + ).toBe(3); + }); + + it('does not accept maximumCasesToOpen to be zero', () => { + const params = getParams(); + + expect(() => + CasesConnectorRunParamsSchema.validate({ + ...params, + maximumCasesToOpen: 0, + }) + ).toThrow(); + }); + + it('does not accept maximumCasesToOpen to be more than 10', () => { + const params = getParams(); + + expect(() => + CasesConnectorRunParamsSchema.validate({ + ...params, + maximumCasesToOpen: 11, + }) + ).toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/schema.ts b/x-pack/plugins/cases/server/connectors/cases/schema.ts new file mode 100644 index 0000000000000..6dba7d53af68e --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/schema.ts @@ -0,0 +1,93 @@ +/* + * 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 dateMath from '@kbn/datemath'; +import { MAX_OPEN_CASES, DEFAULT_MAX_OPEN_CASES } from './constants'; +import { CASES_CONNECTOR_TIME_WINDOW_REGEX } from '../../../common/constants'; + +const AlertSchema = schema.recordOf(schema.string(), schema.any(), { + validate: (value) => { + if (!Object.hasOwn(value, '_id') || !Object.hasOwn(value, '_index')) { + return 'Alert ID and index must be defined'; + } + }, +}); + +/** + * At the moment only one field is supported for grouping + */ +const GroupingSchema = schema.arrayOf(schema.string(), { minSize: 0, maxSize: 1 }); + +const RuleSchema = schema.object({ + id: schema.string(), + name: schema.string(), + tags: schema.arrayOf(schema.string(), { defaultValue: [] }), + ruleUrl: schema.nullable(schema.string()), +}); + +const ReopenClosedCasesSchema = schema.boolean({ defaultValue: false }); +const TimeWindowSchema = schema.string({ + defaultValue: '7d', + validate: (value) => { + /** + * Validates the time window. + * Acceptable format: + * - First character should be a digit from 1 to 9 + * - All next characters should be a digit from 0 to 9 + * - The last character should be d (day) or w (week) + * + * Example: 20d, 2w, etc + */ + const timeWindowRegex = new RegExp(CASES_CONNECTOR_TIME_WINDOW_REGEX, 'g'); + + if (!timeWindowRegex.test(value)) { + return 'Not a valid time window'; + } + + const date = dateMath.parse(`now-${value}`); + + if (!date || !date.isValid()) { + return 'Not a valid time window'; + } + }, +}); + +/** + * The case connector does not have any configuration + * or secrets. + */ +export const CasesConnectorConfigSchema = schema.object({}); +export const CasesConnectorSecretsSchema = schema.object({}); + +export const CasesConnectorRunParamsSchema = schema.object({ + alerts: schema.arrayOf(AlertSchema), + groupingBy: GroupingSchema, + owner: schema.string(), + rule: RuleSchema, + timeWindow: TimeWindowSchema, + reopenClosedCases: ReopenClosedCasesSchema, + maximumCasesToOpen: schema.number({ + defaultValue: DEFAULT_MAX_OPEN_CASES, + min: 1, + max: MAX_OPEN_CASES, + }), +}); + +export const CasesConnectorRuleActionParamsSchema = schema.object({ + subAction: schema.literal('run'), + subActionParams: schema.object({ + groupingBy: GroupingSchema, + reopenClosedCases: ReopenClosedCasesSchema, + timeWindow: TimeWindowSchema, + }), +}); + +export const CasesConnectorParamsSchema = schema.object({ + subAction: schema.literal('run'), + subActionParams: CasesConnectorRunParamsSchema, +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/test_helpers.ts b/x-pack/plugins/cases/server/connectors/cases/test_helpers.ts new file mode 100644 index 0000000000000..9dc2b37785959 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/test_helpers.ts @@ -0,0 +1,116 @@ +/* + * 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 { CasesClientMock } from '../../client/mocks'; + +export const expectCasesToHaveTheCorrectAlertsAttachedWithGrouping = ( + casesClientMock: CasesClientMock +) => { + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(3); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + alertId: ['alert-id-0', 'alert-id-2'], + index: ['alert-index-0', 'alert-index-2'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, { + caseId: 'mock-id-2', + attachments: [ + { + alertId: ['alert-id-1'], + index: ['alert-index-1'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(3, { + caseId: 'mock-id-3', + attachments: [ + { + alertId: ['alert-id-3'], + index: ['alert-index-3'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); +}; + +export const expectCasesToHaveTheCorrectAlertsAttachedWithGroupingAndIncreasedCounter = ( + casesClientMock: CasesClientMock +) => { + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(3); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + alertId: ['alert-id-1'], + index: ['alert-index-1'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(2, { + caseId: 'mock-id-2', + attachments: [ + { + alertId: ['alert-id-3'], + index: ['alert-index-3'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(3, { + caseId: 'mock-id-4', + attachments: [ + { + alertId: ['alert-id-0', 'alert-id-2'], + index: ['alert-index-0', 'alert-index-2'], + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); +}; diff --git a/x-pack/plugins/cases/server/connectors/cases/types.ts b/x-pack/plugins/cases/server/connectors/cases/types.ts new file mode 100644 index 0000000000000..41b380cc7f1ff --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/types.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExclusiveUnion } from '@elastic/eui'; +import type { TypeOf } from '@kbn/config-schema'; +import type { SavedObjectError } from '@kbn/core-saved-objects-common'; +import type { DecoratedError } from '@kbn/core-saved-objects-server'; +import type { + CasesConnectorConfigSchema, + CasesConnectorSecretsSchema, + CasesConnectorRunParamsSchema, + CasesConnectorRuleActionParamsSchema, + CasesConnectorParamsSchema, +} from './schema'; + +export type CasesConnectorConfig = TypeOf; +export type CasesConnectorSecrets = TypeOf; +export type CasesConnectorRunParams = Omit< + TypeOf, + 'alerts' +> & { alerts: Array<{ _id: string; _index: string; [x: string]: unknown }> }; + +type Optional = Pick, K> & Omit; + +interface OracleKeyAllRequired { + ruleId: string; + spaceId: string; + owner: string; + grouping: Record; +} + +type OracleKeyWithOptionalKey = Optional; +type OracleKeyWithOptionalGrouping = Optional; + +export type OracleKey = ExclusiveUnion; + +export type CaseIdPayload = OracleKey & { counter: number }; + +export interface OracleRecord { + id: string; + counter: number; + grouping: Record; + rules: Array<{ id: string }>; + createdAt: string; + updatedAt: string | null; + version: string; +} + +export type OracleSOError = SavedObjectError | DecoratedError; + +export interface OracleRecordError { + id?: string; + error: string; + message: string; + statusCode: number; +} + +export interface OracleRecordCreateRequest { + rules: Array<{ id: string }>; + grouping: Record; +} + +export type BulkGetOracleRecordsResponse = Array; + +export type OracleRecordAttributes = Omit; + +export type BulkCreateOracleRecordRequest = Array<{ + recordId: string; + payload: OracleRecordCreateRequest; +}>; + +export type BulkUpdateOracleRecordRequest = Array<{ + recordId: string; + version: string; + payload: Pick; +}>; + +export interface BackoffStrategy { + nextBackOff: () => number; +} + +export interface BackoffFactory { + create: () => BackoffStrategy; +} + +export type CasesConnectorRuleActionParams = TypeOf; +export type CasesConnectorParams = TypeOf; diff --git a/x-pack/plugins/cases/server/connectors/cases/utils.test.ts b/x-pack/plugins/cases/server/connectors/cases/utils.test.ts new file mode 100644 index 0000000000000..20595c895e552 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/utils.test.ts @@ -0,0 +1,255 @@ +/* + * 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 { + CustomFieldConfiguration, + CustomFieldsConfiguration, +} from '../../../common/types/domain'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { oracleRecordError, oracleRecord } from './index.mock'; +import { + convertValueToString, + isRecordError, + partitionRecordsByError, + buildRequiredCustomFieldsForRequest, + constructRequiredKibanaPrivileges, +} from './utils'; + +describe('utils', () => { + describe('isRecordError', () => { + it('returns true if the record contains an error', () => { + expect(isRecordError(oracleRecordError)).toBe(true); + }); + + it('returns false if the record is an oracle record', () => { + expect(isRecordError(oracleRecord)).toBe(false); + }); + + it('returns false if the record is an empty object', () => { + // @ts-expect-error: need to test for empty objects + expect(isRecordError({})).toBe(false); + }); + }); + + describe('partitionRecordsByError', () => { + it('partition records correctly', () => { + expect( + partitionRecordsByError([oracleRecordError, oracleRecord, oracleRecordError, oracleRecord]) + ).toEqual([ + [oracleRecord, oracleRecord], + [oracleRecordError, oracleRecordError], + ]); + }); + }); + + describe('convertValueToString', () => { + it('converts null correctly', () => { + expect(convertValueToString(null)).toBe(''); + }); + + it('converts undefined correctly', () => { + expect(convertValueToString(undefined)).toBe(''); + }); + + it('converts an array correctly', () => { + expect(convertValueToString([1, 2, 'foo', { foo: 'bar' }])).toBe('[1,2,"foo",{"foo":"bar"}]'); + }); + + it('converts an object correctly', () => { + expect(convertValueToString({ foo: 'bar', baz: 2, qux: [1, 2, 'foo'] })).toBe( + '{"foo":"bar","baz":2,"qux":[1,2,"foo"]}' + ); + }); + + it('converts a number correctly', () => { + expect(convertValueToString(5.2)).toBe('5.2'); + }); + + it('converts a string correctly', () => { + expect(convertValueToString('foo')).toBe('foo'); + }); + + it('converts a boolean correctly', () => { + expect(convertValueToString(true)).toBe('true'); + }); + }); + + describe('buildRequiredCustomFieldsForRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adds required custom fields with default values in configuration', () => { + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'text', + required: true, + defaultValue: 'default value', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'toggle', + required: true, + defaultValue: true, + }, + ]; + + expect(buildRequiredCustomFieldsForRequest(customFieldsConfiguration)).toEqual([ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: 'default value', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: true, + }, + ]); + }); + + it('adds required custom fields without default values in configuration', () => { + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'text', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'toggle', + required: true, + }, + ]; + + expect(buildRequiredCustomFieldsForRequest(customFieldsConfiguration)).toEqual([ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: 'N/A', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: false, + }, + ]); + }); + + it('does not add optional fields with or without default values in configuration', () => { + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'text 1', + required: false, + defaultValue: 'default value', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'toggle 1', + required: false, + defaultValue: false, + }, + { + key: 'third_key', + type: CustomFieldTypes.TEXT, + label: 'text 2', + required: false, + }, + { + key: 'fourth_key', + type: CustomFieldTypes.TOGGLE, + label: 'toggle 2', + required: false, + }, + ]; + + expect(buildRequiredCustomFieldsForRequest(customFieldsConfiguration)).toEqual([]); + }); + + it('handles correctly a mix of required and optional custom fields', () => { + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'text 1', + required: false, + defaultValue: 'default value', + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'toggle 1', + required: false, + defaultValue: false, + }, + { + key: 'third_key', + type: CustomFieldTypes.TEXT, + label: 'text 2', + required: true, + }, + { + key: 'fourth_key', + type: CustomFieldTypes.TOGGLE, + label: 'toggle 2', + required: false, + }, + ]; + + expect(buildRequiredCustomFieldsForRequest(customFieldsConfiguration)).toEqual([ + { + key: 'third_key', + type: CustomFieldTypes.TEXT, + value: 'N/A', + }, + ]); + }); + + it('ensure we can generate for every possible custom field type', () => { + // this test should fail if a new custom field is added and the builder is not updated + const customFieldsConfiguration: CustomFieldsConfiguration = Object.keys( + CustomFieldTypes + ).map( + (type) => + ({ + key: `key-${type}`, + type, + label: `label-${type}`, + required: true, + // missing default value + } as CustomFieldConfiguration) + ); + + expect(buildRequiredCustomFieldsForRequest(customFieldsConfiguration).length).toEqual( + customFieldsConfiguration.length + ); + }); + }); + + describe('constructRequiredKibanaPrivileges', () => { + it('construct the required kibana privileges correctly', () => { + expect(constructRequiredKibanaPrivileges('my-owner')).toEqual([ + 'cases:my-owner/createCase', + 'cases:my-owner/updateCase', + 'cases:my-owner/deleteCase', + 'cases:my-owner/pushCase', + 'cases:my-owner/createComment', + 'cases:my-owner/updateComment', + 'cases:my-owner/deleteComment', + 'cases:my-owner/findConfigurations', + ]); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/utils.ts b/x-pack/plugins/cases/server/connectors/cases/utils.ts new file mode 100644 index 0000000000000..c17cde47e4298 --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/utils.ts @@ -0,0 +1,97 @@ +/* + * 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 { isPlainObject, partition, toString } from 'lodash'; +import type { CaseRequestCustomField, CaseRequestCustomFields } from '../../../common/types/api'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import { VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS } from './constants'; +import type { BulkGetOracleRecordsResponse, OracleRecord, OracleRecordError } from './types'; + +export const isRecordError = (so: OracleRecord | OracleRecordError): so is OracleRecordError => + (so as OracleRecordError).error != null; + +export const partitionRecordsByError = ( + res: BulkGetOracleRecordsResponse +): [OracleRecord[], OracleRecordError[]] => { + const [errors, validRecords] = partition(res, isRecordError) as [ + OracleRecordError[], + OracleRecord[] + ]; + + return [validRecords, errors]; +}; + +export const partitionByNonFoundErrors = >( + errors: T +): [T, T] => { + const [nonFoundErrors, restOfErrors] = partition(errors, (error) => error.statusCode === 404) as [ + T, + T + ]; + + return [nonFoundErrors, restOfErrors]; +}; + +export const convertValueToString = (value: unknown): string => { + if (value == null) { + return ''; + } + + if (Array.isArray(value) || isPlainObject(value)) { + try { + return JSON.stringify(value); + } catch (error) { + return ''; + } + } + + return toString(value); +}; + +export const buildRequiredCustomFieldsForRequest = ( + customFieldsConfiguration?: CustomFieldsConfiguration +): CaseRequestCustomFields => { + // only populate with the default value required custom fields missing from the request + return customFieldsConfiguration + ? customFieldsConfiguration + .filter((customFieldConfig) => customFieldConfig.required) + .map((customFieldConfig) => { + let value = null; + + if (customFieldConfig.type in VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS) { + value = + customFieldConfig.defaultValue === undefined || + customFieldConfig?.defaultValue === null + ? VALUES_FOR_CUSTOM_FIELDS_MISSING_DEFAULTS[customFieldConfig.type] + : customFieldConfig?.defaultValue; + } + + return { + key: customFieldConfig.key, + type: customFieldConfig.type, + value, + } as CaseRequestCustomField; + }) + : []; +}; + +export const constructRequiredKibanaPrivileges = (owner: string): string[] => { + /** + * Kibana features privileges are defined in + * x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts + */ + return [ + `cases:${owner}/createCase`, + `cases:${owner}/updateCase`, + `cases:${owner}/deleteCase`, + `cases:${owner}/pushCase`, + `cases:${owner}/createComment`, + `cases:${owner}/updateComment`, + `cases:${owner}/deleteComment`, + `cases:${owner}/findConfigurations`, + ]; +}; diff --git a/x-pack/plugins/cases/server/connectors/index.ts b/x-pack/plugins/cases/server/connectors/index.ts index 78b83223a3d66..2d680163dde28 100644 --- a/x-pack/plugins/cases/server/connectors/index.ts +++ b/x-pack/plugins/cases/server/connectors/index.ts @@ -5,5 +5,56 @@ * 2.0. */ +import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { CoreSetup, SavedObjectsClientContract } from '@kbn/core/server'; +import { SECURITY_EXTENSION_ID } from '@kbn/core/server'; +import type { PluginSetupContract as AlertingPluginSetup } from '@kbn/alerting-plugin/server'; +import type { CasesClient } from '../client'; +import { getCasesConnectorAdapter, getCasesConnectorType } from './cases'; + export * from './types'; export { casesConnectors } from './factory'; + +export function registerConnectorTypes({ + alerting, + actions, + core, + getCasesClient, + getSpaceId, +}: { + actions: ActionsPluginSetupContract; + alerting: AlertingPluginSetup; + core: CoreSetup; + getCasesClient: (request: KibanaRequest) => Promise; + getSpaceId: (request?: KibanaRequest) => string; +}) { + const getUnsecuredSavedObjectsClient = async ( + request: KibanaRequest, + savedObjectTypes: string[] + ): Promise => { + const [coreStart] = await core.getStartServices(); + + /** + * The actions framework ensures that the user executing the case action + * will have permissions to use cases for the corresponding owner and space. + * The required Kibana privileges needed to execute the case action are defined + * in x-pack/plugins/cases/server/connectors/cases/index.ts. + * + * We can safely disable security checks performed by the saved object client + * as we implement our custom authorization. + */ + const unsecuredSavedObjectsClient = coreStart.savedObjects.getScopedClient(request, { + includedHiddenTypes: savedObjectTypes, + excludedExtensions: [SECURITY_EXTENSION_ID], + }); + + return unsecuredSavedObjectsClient; + }; + + actions.registerSubActionConnectorType( + getCasesConnectorType({ getCasesClient, getSpaceId, getUnsecuredSavedObjectsClient }) + ); + + alerting.registerConnectorAdapter(getCasesConnectorAdapter()); +} diff --git a/x-pack/plugins/cases/server/plugin.test.ts b/x-pack/plugins/cases/server/plugin.test.ts index ad852fc65ebc5..8c669f6de0e68 100644 --- a/x-pack/plugins/cases/server/plugin.test.ts +++ b/x-pack/plugins/cases/server/plugin.test.ts @@ -48,6 +48,7 @@ describe('Cases Plugin', () => { coreStart = coreMock.createStart(); pluginsSetup = { + alerting: alertsMock.createSetup(), taskManager: taskManagerMock.createSetup(), actions: actionsMock.createSetup(), files: createFilesSetupMock(), diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index cc77798be74e2..2c3f1f10ad254 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -18,15 +18,8 @@ import type { import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; import type { LensServerPluginSetup } from '@kbn/lens-plugin/server'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import { APP_ID } from '../common/constants'; -import { - createCaseCommentSavedObjectType, - caseConfigureSavedObjectType, - caseConnectorMappingsSavedObjectType, - createCaseSavedObjectType, - createCaseUserActionSavedObjectType, - casesTelemetrySavedObjectType, -} from './saved_object_types'; import type { CasesClient } from './client'; import type { @@ -49,6 +42,8 @@ import { LICENSING_CASE_ASSIGNMENT_FEATURE } from './common/constants'; import { registerInternalAttachments } from './internal_attachments'; import { registerCaseFileKinds } from './files'; import type { ConfigType } from './config'; +import { registerConnectorTypes } from './connectors'; +import { registerSavedObjects } from './saved_object_types'; export class CasePlugin implements @@ -90,6 +85,7 @@ export class CasePlugin this.externalReferenceAttachmentTypeRegistry, this.persistableStateAttachmentTypeRegistry ); + registerCaseFileKinds(this.caseConfig.files, plugins.files); this.securityPluginSetup = plugins.security; @@ -99,23 +95,12 @@ export class CasePlugin plugins.features.registerKibanaFeature(getCasesKibanaFeature()); } - core.savedObjects.registerType( - createCaseCommentSavedObjectType({ - migrationDeps: { - persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry, - lensEmbeddableFactory: this.lensEmbeddableFactory, - }, - }) - ); - core.savedObjects.registerType(caseConfigureSavedObjectType); - core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); - core.savedObjects.registerType(createCaseSavedObjectType(core, this.logger)); - core.savedObjects.registerType( - createCaseUserActionSavedObjectType({ - persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry, - }) - ); - core.savedObjects.registerType(casesTelemetrySavedObjectType); + registerSavedObjects({ + core, + logger: this.logger, + persistableStateAttachmentTypeRegistry: this.persistableStateAttachmentTypeRegistry, + lensEmbeddableFactory: this.lensEmbeddableFactory, + }); core.http.registerRouteHandlerContext( APP_ID, @@ -147,6 +132,27 @@ export class CasePlugin plugins.licensing.featureUsage.register(LICENSING_CASE_ASSIGNMENT_FEATURE, 'platinum'); + const getCasesClient = async (request: KibanaRequest): Promise => { + const [coreStart] = await core.getStartServices(); + return this.getCasesClientWithRequest(coreStart)(request); + }; + + const getSpaceId = (request?: KibanaRequest) => { + if (!request) { + return DEFAULT_SPACE_ID; + } + + return plugins.spaces?.spacesService.getSpaceId(request) ?? DEFAULT_SPACE_ID; + }; + + registerConnectorTypes({ + actions: plugins.actions, + alerting: plugins.alerting, + core, + getCasesClient, + getSpaceId, + }); + return { attachmentFramework: { registerExternalReference: (externalReferenceAttachmentType) => { @@ -198,18 +204,8 @@ export class CasePlugin filesPluginStart: plugins.files, }); - const client = core.elasticsearch.client; - - const getCasesClientWithRequest = async (request: KibanaRequest): Promise => { - return this.clientFactory.create({ - request, - scopedClusterClient: client.asScoped(request).asCurrentUser, - savedObjectsService: core.savedObjects, - }); - }; - return { - getCasesClientWithRequest, + getCasesClientWithRequest: this.getCasesClientWithRequest(core), getExternalReferenceAttachmentTypeRegistry: () => this.externalReferenceAttachmentTypeRegistry, getPersistableStateAttachmentTypeRegistry: () => this.persistableStateAttachmentTypeRegistry, @@ -240,4 +236,16 @@ export class CasePlugin }; }; }; + + private getCasesClientWithRequest = + (core: CoreStart) => + async (request: KibanaRequest): Promise => { + const client = core.elasticsearch.client; + + return this.clientFactory.create({ + request, + scopedClusterClient: client.asScoped(request).asCurrentUser, + savedObjectsService: core.savedObjects, + }); + }; } diff --git a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts index 16f3c8e6baa60..f9751c2ae8f12 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts @@ -20,7 +20,7 @@ export const patchCaseRoute = createCasesRoute({ const casesClient = await caseContext.getCasesClient(); const cases = request.body as caseApiV1.CasesPatchRequest; - const res: caseDomainV1.Cases = await casesClient.cases.update(cases); + const res: caseDomainV1.Cases = await casesClient.cases.bulkUpdate(cases); return response.ok({ body: res, diff --git a/x-pack/plugins/cases/server/saved_object_types/cases_rules.ts b/x-pack/plugins/cases/server/saved_object_types/cases_rules.ts new file mode 100644 index 0000000000000..98bf10dc9d76b --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/cases_rules.ts @@ -0,0 +1,61 @@ +/* + * 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 { SavedObjectsType } from '@kbn/core/server'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { schema } from '@kbn/config-schema'; +import { CASE_RULES_SAVED_OBJECT } from '../../common/constants'; + +export const casesRulesSavedObjectType: SavedObjectsType = { + name: CASE_RULES_SAVED_OBJECT, + indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, + hidden: true, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: { + counter: { + type: 'unsigned_long', + }, + createdAt: { + type: 'date', + }, + /* + grouping: { + type: 'flattened', + }, + */ + rules: { + properties: { + id: { + type: 'keyword', + }, + }, + }, + updatedAt: { + type: 'date', + }, + }, + }, + management: { + importableAndExportable: false, + }, + modelVersions: { + '1': { + changes: [], + schemas: { + create: schema.object({ + counter: schema.number(), + createdAt: schema.string(), + grouping: schema.recordOf(schema.string(), schema.any()), + rules: schema.arrayOf(schema.object({ id: schema.string() })), + updatedAt: schema.nullable(schema.string()), + }), + }, + }, + }, +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/index.ts b/x-pack/plugins/cases/server/saved_object_types/index.ts index a43e60c0a240b..b4a6a1e01b292 100644 --- a/x-pack/plugins/cases/server/saved_object_types/index.ts +++ b/x-pack/plugins/cases/server/saved_object_types/index.ts @@ -5,9 +5,48 @@ * 2.0. */ -export { createCaseSavedObjectType } from './cases/cases'; -export { caseConfigureSavedObjectType } from './configure'; -export { createCaseCommentSavedObjectType } from './comments'; -export { createCaseUserActionSavedObjectType } from './user_actions'; -export { caseConnectorMappingsSavedObjectType } from './connector_mappings'; -export { casesTelemetrySavedObjectType } from './telemetry'; +import type { CoreSetup, Logger } from '@kbn/core/server'; +import type { LensServerPluginSetup } from '@kbn/lens-plugin/server'; +import { createCaseSavedObjectType } from './cases/cases'; +import { caseConfigureSavedObjectType } from './configure'; +import { createCaseCommentSavedObjectType } from './comments'; +import { createCaseUserActionSavedObjectType } from './user_actions'; +import { caseConnectorMappingsSavedObjectType } from './connector_mappings'; +import { casesTelemetrySavedObjectType } from './telemetry'; +import { casesRulesSavedObjectType } from './cases_rules'; +import type { PersistableStateAttachmentTypeRegistry } from '../attachment_framework/persistable_state_registry'; + +interface RegisterSavedObjectsArgs { + core: CoreSetup; + logger: Logger; + persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; + lensEmbeddableFactory: LensServerPluginSetup['lensEmbeddableFactory']; +} + +export const registerSavedObjects = ({ + core, + logger, + persistableStateAttachmentTypeRegistry, + lensEmbeddableFactory, +}: RegisterSavedObjectsArgs) => { + core.savedObjects.registerType( + createCaseCommentSavedObjectType({ + migrationDeps: { + persistableStateAttachmentTypeRegistry, + lensEmbeddableFactory, + }, + }) + ); + + core.savedObjects.registerType(caseConfigureSavedObjectType); + core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); + core.savedObjects.registerType(createCaseSavedObjectType(core, logger)); + core.savedObjects.registerType( + createCaseUserActionSavedObjectType({ + persistableStateAttachmentTypeRegistry, + }) + ); + + core.savedObjects.registerType(casesTelemetrySavedObjectType); + core.savedObjects.registerType(casesRulesSavedObjectType); +}; diff --git a/x-pack/plugins/cases/server/telemetry/collect_telemetry_data.ts b/x-pack/plugins/cases/server/telemetry/collect_telemetry_data.ts index c3a595870c9ec..cabb7743a540d 100644 --- a/x-pack/plugins/cases/server/telemetry/collect_telemetry_data.ts +++ b/x-pack/plugins/cases/server/telemetry/collect_telemetry_data.ts @@ -7,6 +7,7 @@ import { getAlertsTelemetryData } from './queries/alerts'; import { getCasesTelemetryData } from './queries/cases'; +import { getCasesSystemActionData } from './queries/case_system_action'; import { getUserCommentsTelemetryData } from './queries/comments'; import { getConfigurationTelemetryData } from './queries/configuration'; import { getConnectorsTelemetryData } from './queries/connectors'; @@ -19,16 +20,25 @@ export const collectTelemetryData = async ({ logger, }: CollectTelemetryDataParams): Promise> => { try { - const [cases, userActions, comments, alerts, connectors, pushes, configuration] = - await Promise.all([ - getCasesTelemetryData({ savedObjectsClient, logger }), - getUserActionsTelemetryData({ savedObjectsClient, logger }), - getUserCommentsTelemetryData({ savedObjectsClient, logger }), - getAlertsTelemetryData({ savedObjectsClient, logger }), - getConnectorsTelemetryData({ savedObjectsClient, logger }), - getPushedTelemetryData({ savedObjectsClient, logger }), - getConfigurationTelemetryData({ savedObjectsClient, logger }), - ]); + const [ + cases, + userActions, + comments, + alerts, + connectors, + pushes, + configuration, + casesSystemAction, + ] = await Promise.all([ + getCasesTelemetryData({ savedObjectsClient, logger }), + getUserActionsTelemetryData({ savedObjectsClient, logger }), + getUserCommentsTelemetryData({ savedObjectsClient, logger }), + getAlertsTelemetryData({ savedObjectsClient, logger }), + getConnectorsTelemetryData({ savedObjectsClient, logger }), + getPushedTelemetryData({ savedObjectsClient, logger }), + getConfigurationTelemetryData({ savedObjectsClient, logger }), + getCasesSystemActionData({ savedObjectsClient, logger }), + ]); return { cases, @@ -38,6 +48,7 @@ export const collectTelemetryData = async ({ connectors, pushes, configuration, + casesSystemAction, }; } catch (err) { logger.debug('Failed collecting Cases telemetry data'); diff --git a/x-pack/plugins/cases/server/telemetry/index.ts b/x-pack/plugins/cases/server/telemetry/index.ts index 7ef14541ced09..af1e9125a8a55 100644 --- a/x-pack/plugins/cases/server/telemetry/index.ts +++ b/x-pack/plugins/cases/server/telemetry/index.ts @@ -21,6 +21,7 @@ import { CASES_TELEMETRY_TASK_NAME, CASE_TELEMETRY_SAVED_OBJECT_ID, SAVED_OBJECT_TYPES, + CASE_RULES_SAVED_OBJECT, } from '../../common/constants'; import type { CasesTelemetry } from './types'; import { casesSchema } from './schema'; @@ -43,7 +44,11 @@ export const createCasesTelemetry = async ({ }: CreateCasesTelemetryArgs) => { const getInternalSavedObjectClient = async (): Promise => { const [coreStart] = await core.getStartServices(); - return coreStart.savedObjects.createInternalRepository([...SAVED_OBJECT_TYPES, FILE_SO_TYPE]); + return coreStart.savedObjects.createInternalRepository([ + ...SAVED_OBJECT_TYPES, + FILE_SO_TYPE, + CASE_RULES_SAVED_OBJECT, + ]); }; taskManager.registerTaskDefinitions({ diff --git a/x-pack/plugins/cases/server/telemetry/queries/case_system_action.test.ts b/x-pack/plugins/cases/server/telemetry/queries/case_system_action.test.ts new file mode 100644 index 0000000000000..6009d646431ed --- /dev/null +++ b/x-pack/plugins/cases/server/telemetry/queries/case_system_action.test.ts @@ -0,0 +1,45 @@ +/* + * 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 { loggingSystemMock, savedObjectsRepositoryMock } from '@kbn/core/server/mocks'; +import { getCasesSystemActionData } from './case_system_action'; + +describe('casesSystemAction', () => { + const logger = loggingSystemMock.createLogger(); + + describe('getCasesSystemActionData', () => { + const savedObjectsClient = savedObjectsRepositoryMock.create(); + + beforeEach(() => { + jest.clearAllMocks(); + savedObjectsClient.find.mockResolvedValue({ + total: 1, + saved_objects: [], + per_page: 1, + page: 1, + aggregations: { counterSum: { value: 4 }, totalRules: { value: 2 } }, + }); + }); + + it('calculates the metrics correctly', async () => { + const res = await getCasesSystemActionData({ savedObjectsClient, logger }); + expect(res).toEqual({ totalCasesCreated: 4, totalRules: 2 }); + }); + + it('calculates the metrics correctly with no aggregations', async () => { + savedObjectsClient.find.mockResolvedValue({ + total: 1, + saved_objects: [], + per_page: 1, + page: 1, + }); + + const res = await getCasesSystemActionData({ savedObjectsClient, logger }); + expect(res).toEqual({ totalCasesCreated: 0, totalRules: 0 }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/telemetry/queries/case_system_action.ts b/x-pack/plugins/cases/server/telemetry/queries/case_system_action.ts new file mode 100644 index 0000000000000..0e05006e3c437 --- /dev/null +++ b/x-pack/plugins/cases/server/telemetry/queries/case_system_action.ts @@ -0,0 +1,35 @@ +/* + * 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 { CASE_RULES_SAVED_OBJECT } from '../../../common/constants'; +import type { CasesTelemetry, CollectTelemetryDataParams } from '../types'; + +interface Aggs { + counterSum: { value: number }; + totalRules: { value: number }; +} + +export const getCasesSystemActionData = async ({ + savedObjectsClient, +}: CollectTelemetryDataParams): Promise => { + const res = await savedObjectsClient.find({ + page: 1, + perPage: 1, + type: CASE_RULES_SAVED_OBJECT, + aggs: { + counterSum: { sum: { field: `${CASE_RULES_SAVED_OBJECT}.attributes.counter` } }, + totalRules: { + cardinality: { field: `${CASE_RULES_SAVED_OBJECT}.attributes.rules.id` }, + }, + }, + }); + + return { + totalCasesCreated: res.aggregations?.counterSum?.value ?? 0, + totalRules: res.aggregations?.totalRules?.value ?? 0, + }; +}; diff --git a/x-pack/plugins/cases/server/telemetry/schema.ts b/x-pack/plugins/cases/server/telemetry/schema.ts index e5222d2f1a909..7fe2a67bfafcf 100644 --- a/x-pack/plugins/cases/server/telemetry/schema.ts +++ b/x-pack/plugins/cases/server/telemetry/schema.ts @@ -145,4 +145,8 @@ export const casesSchema: CasesTelemetrySchema = { obs: customFieldsSolutionTelemetrySchema, main: customFieldsSolutionTelemetrySchema, }, + casesSystemAction: { + totalCasesCreated: long, + totalRules: long, + }, }; diff --git a/x-pack/plugins/cases/server/telemetry/types.ts b/x-pack/plugins/cases/server/telemetry/types.ts index 4cfcb2d275a04..294efdbce1125 100644 --- a/x-pack/plugins/cases/server/telemetry/types.ts +++ b/x-pack/plugins/cases/server/telemetry/types.ts @@ -213,6 +213,10 @@ export interface CasesTelemetry { obs: CustomFieldsSolutionTelemetry; main: CustomFieldsSolutionTelemetry; }; + casesSystemAction: { + totalCasesCreated: number; + totalRules: number; + }; } export type CountSchema = MakeSchemaFrom; diff --git a/x-pack/plugins/cases/server/types.ts b/x-pack/plugins/cases/server/types.ts index d0cbf370f729a..f404f7346144c 100644 --- a/x-pack/plugins/cases/server/types.ts +++ b/x-pack/plugins/cases/server/types.ts @@ -18,7 +18,7 @@ import type { PluginSetupContract as ActionsPluginSetup, PluginStartContract as ActionsPluginStart, } from '@kbn/actions-plugin/server'; -import type { SpacesPluginStart } from '@kbn/spaces-plugin/server'; +import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server'; import type { PluginStartContract as FeaturesPluginStart, PluginSetupContract as FeaturesPluginSetup, @@ -32,12 +32,14 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/server'; import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server'; import type { RuleRegistryPluginStartContract } from '@kbn/rule-registry-plugin/server'; +import type { PluginSetupContract as AlertingPluginSetup } from '@kbn/alerting-plugin/server'; import type { CasesClient } from './client'; import type { AttachmentFramework } from './attachment_framework/types'; import type { ExternalReferenceAttachmentTypeRegistry } from './attachment_framework/external_reference_registry'; import type { PersistableStateAttachmentTypeRegistry } from './attachment_framework/persistable_state_registry'; export interface CasesServerSetupDependencies { + alerting: AlertingPluginSetup; actions: ActionsPluginSetup; lens: LensServerPluginSetup; features: FeaturesPluginSetup; @@ -46,6 +48,7 @@ export interface CasesServerSetupDependencies { licensing: LicensingPluginSetup; taskManager?: TaskManagerSetupContract; usageCollection?: UsageCollectionSetup; + spaces?: SpacesPluginSetup; } export interface CasesServerStartDependencies { diff --git a/x-pack/plugins/cases/tsconfig.json b/x-pack/plugins/cases/tsconfig.json index bf205c79e50c8..b5a426c3f9c0b 100644 --- a/x-pack/plugins/cases/tsconfig.json +++ b/x-pack/plugins/cases/tsconfig.json @@ -70,6 +70,10 @@ "@kbn/core-application-browser", "@kbn/react-kibana-context-render", "@kbn/react-kibana-mount", + "@kbn/datemath", + "@kbn/core-logging-server-mocks", + "@kbn/core-logging-browser-mocks", + "@kbn/data-views-plugin", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts index b54ba77777dd8..0c56e455e05f5 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts @@ -18,7 +18,7 @@ export type CasesSupportedOperations = typeof allOperations[number]; * extend the mapping here x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts * * Also if you add a new operation (createCase, updateCase, etc) here you'll likely also need to make changes here: - * x-pack/plugins/cases/server/authorization/index.ts + * x-pack/plugins/cases/server/authorization/index.ts and here x-pack/plugins/cases/server/connectors/cases/utils.ts */ const pushOperations = ['pushCase'] as const; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 9c5add1da84eb..9143016851c50 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -7084,6 +7084,16 @@ } } } + }, + "casesSystemAction": { + "properties": { + "totalCasesCreated": { + "type": "long" + }, + "totalRules": { + "type": "long" + } + } } } }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 641c480603e95..cdcf0284025e3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -571,6 +571,7 @@ export const ActionTypeForm = ({ actionConnector={actionConnector} executionMode={ActionConnectorMode.ActionForm} ruleTypeId={ruleTypeId} + producerId={producerId} /> {warning ? ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/system_action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/system_action_type_form.tsx index 62263a7f87ad4..06d0aa17c2b7d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/system_action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/system_action_type_form.tsx @@ -195,6 +195,7 @@ export const SystemActionTypeForm = ({ actionConnector={actionConnector} executionMode={ActionConnectorMode.ActionForm} ruleTypeId={ruleTypeId} + producerId={producerId} /> {warning ? ( <> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx index a4e0ea59e685c..d8d01470fa9c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_actions.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { EuiText, EuiSpacer, @@ -52,18 +52,20 @@ export function RuleActions({ ); } - const getNotifyText = (action: RuleUiAction, isSystemAction?: boolean) => { + const getNotifyText = (action: RuleUiAction, isSystemAction?: boolean): string | ReactNode => { if (isSystemAction) { return NOTIFY_WHEN_OPTIONS[1].inputDisplay; } - return ( - ('frequency' in action && - (NOTIFY_WHEN_OPTIONS.find((options) => options.value === action.frequency?.notifyWhen) - ?.inputDisplay || - action.frequency?.notifyWhen)) ?? - legacyNotifyWhen - ); + if ('frequency' in action) { + const notifyWhen = NOTIFY_WHEN_OPTIONS.find( + (options) => options.value === action.frequency?.notifyWhen + ); + + return notifyWhen?.inputDisplay ?? action.frequency?.notifyWhen ?? legacyNotifyWhen ?? ''; + } + + return ''; }; const getActionIconClass = (actionGroupId?: string): IconType | undefined => { @@ -85,6 +87,7 @@ export function RuleActions({ {ruleActions.map((action, index) => { const { actionTypeId, id } = action; const actionName = getActionName(id); + return ( @@ -105,7 +108,9 @@ export function RuleActions({ {String( diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 1bd1b44341e02..da994b5f8cd1c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -256,6 +256,7 @@ export interface ActionParamsProps { showEmailSubjectAndMessage?: boolean; executionMode?: ActionConnectorMode; onBlur?: (field?: string) => void; + producerId?: string; } export interface Pagination { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/bulk_enqueue.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/bulk_enqueue.ts index 599f370122358..acf6e86652862 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/bulk_enqueue.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/bulk_enqueue.ts @@ -100,7 +100,7 @@ export default function ({ getService }: FtrProviderContext) { const startDate = new Date().toISOString(); const connectorId = 'system-connector-test.system-action-kibana-privileges'; - const name = 'System action: test.system-action-kibana-privileges'; + const name = 'Test system action with kibana privileges'; const reference = `actions-enqueue-${scenario.id}:${space.id}:${connectorId}`; const response = await supertestWithoutAuth diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts index 448296a4ae00c..acfb06e64cff6 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/execute.ts @@ -508,7 +508,7 @@ export default function ({ getService }: FtrProviderContext) { it('should authorize system actions correctly', async () => { const startDate = new Date().toISOString(); const connectorId = 'system-connector-test.system-action-kibana-privileges'; - const name = 'System action: test.system-action-kibana-privileges'; + const name = 'Test system action with kibana privileges'; const reference = `actions-enqueue-${scenario.id}:${space.id}:${connectorId}`; /** diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts index 2811c7e2d4ce2..75f34b1458bd1 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/get_all_system.ts @@ -68,6 +68,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); expect(nonCustomSslConnectors).to.eql([ + { + connector_type_id: '.cases', + id: 'system-connector-.cases', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'Cases', + referenced_by_count: 0, + }, { id: createdAction.id, is_preconfigured: false, @@ -126,13 +135,22 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'Slack#xyz', referenced_by_count: 0, }, + { + id: 'custom-system-abc-connector', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: 'system-abc-action-type', + name: 'SystemABC', + referenced_by_count: 0, + }, { connector_type_id: 'test.system-action', id: 'system-connector-test.system-action', is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action', + name: 'Test system action', referenced_by_count: 0, }, { @@ -141,7 +159,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action-connector-adapter', + name: 'Test system action with a connector adapter set', referenced_by_count: 0, }, { @@ -150,16 +168,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action-kibana-privileges', - referenced_by_count: 0, - }, - { - id: 'custom-system-abc-connector', - is_preconfigured: true, - is_system_action: false, - is_deprecated: false, - connector_type_id: 'system-abc-action-type', - name: 'SystemABC', + name: 'Test system action with kibana privileges', referenced_by_count: 0, }, { @@ -255,6 +264,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); expect(nonCustomSslConnectors).to.eql([ + { + connector_type_id: '.cases', + id: 'system-connector-.cases', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'Cases', + referenced_by_count: 0, + }, { id: createdAction.id, is_preconfigured: false, @@ -313,13 +331,22 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'Slack#xyz', referenced_by_count: 0, }, + { + id: 'custom-system-abc-connector', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: 'system-abc-action-type', + name: 'SystemABC', + referenced_by_count: 0, + }, { connector_type_id: 'test.system-action', id: 'system-connector-test.system-action', is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action', + name: 'Test system action', referenced_by_count: 0, }, { @@ -328,7 +355,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action-connector-adapter', + name: 'Test system action with a connector adapter set', referenced_by_count: 0, }, { @@ -337,18 +364,10 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action-kibana-privileges', - referenced_by_count: 0, - }, - { - id: 'custom-system-abc-connector', - is_preconfigured: true, - is_system_action: false, - is_deprecated: false, - connector_type_id: 'system-abc-action-type', - name: 'SystemABC', + name: 'Test system action with kibana privileges', referenced_by_count: 0, }, + { id: 'preconfigured.test.index-record', is_preconfigured: true, @@ -418,6 +437,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { (conn: { id: string }) => !conn.id.startsWith('custom.ssl.') ); expect(nonCustomSslConnectors).to.eql([ + { + connector_type_id: '.cases', + id: 'system-connector-.cases', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'Cases', + referenced_by_count: 0, + }, { connector_type_id: '.email', id: 'notification-email', @@ -463,13 +491,22 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'Slack#xyz', referenced_by_count: 0, }, + { + id: 'custom-system-abc-connector', + is_preconfigured: true, + is_system_action: false, + is_deprecated: false, + connector_type_id: 'system-abc-action-type', + name: 'SystemABC', + referenced_by_count: 0, + }, { connector_type_id: 'test.system-action', id: 'system-connector-test.system-action', is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action', + name: 'Test system action', referenced_by_count: 0, }, { @@ -478,7 +515,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action-connector-adapter', + name: 'Test system action with a connector adapter set', referenced_by_count: 0, }, { @@ -487,16 +524,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action-kibana-privileges', - referenced_by_count: 0, - }, - { - id: 'custom-system-abc-connector', - is_preconfigured: true, - is_system_action: false, - is_deprecated: false, - connector_type_id: 'system-abc-action-type', - name: 'SystemABC', + name: 'Test system action with kibana privileges', referenced_by_count: 0, }, { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/alerts.ts index 34f07f70f0216..9d3cac9ef9a6d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group4/tests/alerting/alerts.ts @@ -1880,7 +1880,7 @@ instanceStateValue: true const space = SuperuserAtSpace1.space; const connectorId = 'system-connector-test.system-action-connector-adapter'; - const name = 'System action: test.system-action-connector-adapter'; + const name = 'Test system action with a connector adapter set'; it('should use connector adapters correctly on system actions', async () => { const alertUtils = new AlertUtils({ diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts index 14094f4a00f8e..a2960d819463b 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts @@ -51,6 +51,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr '.gen-ai', '.bedrock', '.sentinelone', + '.cases', ].sort() ); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts index 45d469eb1132d..17187505aa2ad 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/execute.ts @@ -342,7 +342,7 @@ export default function ({ getService }: FtrProviderContext) { */ it('should execute system actions correctly', async () => { const connectorId = 'system-connector-test.system-action'; - const name = 'System action: test.system-action'; + const name = 'Test system action'; const response = await supertest .post( @@ -375,7 +375,7 @@ export default function ({ getService }: FtrProviderContext) { */ it('should execute system actions with kibana privileges correctly', async () => { const connectorId = 'system-connector-test.system-action-kibana-privileges'; - const name = 'System action: test.system-action-kibana-privileges'; + const name = 'Test system action with kibana privileges'; const response = await supertest .post( diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts index 014a894db7d31..2690ff487ff5d 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/get_all_system.ts @@ -56,6 +56,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_system_action: false, referenced_by_count: 0, }, + { + connector_type_id: '.cases', + id: 'system-connector-.cases', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'Cases', + referenced_by_count: 0, + }, { id: createdAction.id, is_preconfigured: false, @@ -114,13 +123,22 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'Slack#xyz', referenced_by_count: 0, }, + { + id: 'custom-system-abc-connector', + is_preconfigured: true, + is_deprecated: false, + connector_type_id: 'system-abc-action-type', + is_system_action: false, + name: 'SystemABC', + referenced_by_count: 0, + }, { connector_type_id: 'test.system-action', id: 'system-connector-test.system-action', is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action', + name: 'Test system action', referenced_by_count: 0, }, { @@ -129,7 +147,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action-connector-adapter', + name: 'Test system action with a connector adapter set', referenced_by_count: 0, }, { @@ -138,18 +156,10 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action-kibana-privileges', - referenced_by_count: 0, - }, - { - id: 'custom-system-abc-connector', - is_preconfigured: true, - is_deprecated: false, - connector_type_id: 'system-abc-action-type', - is_system_action: false, - name: 'SystemABC', + name: 'Test system action with kibana privileges', referenced_by_count: 0, }, + { id: 'preconfigured.test.index-record', is_preconfigured: true, @@ -208,6 +218,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_system_action: false, referenced_by_count: 0, }, + { + connector_type_id: '.cases', + id: 'system-connector-.cases', + is_deprecated: false, + is_preconfigured: false, + is_system_action: true, + name: 'Cases', + referenced_by_count: 0, + }, { connector_type_id: '.email', id: 'notification-email', @@ -253,13 +272,22 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { name: 'Slack#xyz', referenced_by_count: 0, }, + { + id: 'custom-system-abc-connector', + is_preconfigured: true, + is_deprecated: false, + is_system_action: false, + connector_type_id: 'system-abc-action-type', + name: 'SystemABC', + referenced_by_count: 0, + }, { connector_type_id: 'test.system-action', id: 'system-connector-test.system-action', is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action', + name: 'Test system action', referenced_by_count: 0, }, { @@ -268,7 +296,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action-connector-adapter', + name: 'Test system action with a connector adapter set', referenced_by_count: 0, }, { @@ -277,16 +305,7 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { is_deprecated: false, is_preconfigured: false, is_system_action: true, - name: 'System action: test.system-action-kibana-privileges', - referenced_by_count: 0, - }, - { - id: 'custom-system-abc-connector', - is_preconfigured: true, - is_deprecated: false, - is_system_action: false, - connector_type_id: 'system-abc-action-type', - name: 'SystemABC', + name: 'Test system action with kibana privileges', referenced_by_count: 0, }, { diff --git a/x-pack/test/cases_api_integration/common/config.ts b/x-pack/test/cases_api_integration/common/config.ts index 7b2927e733d8f..4cf07f238a528 100644 --- a/x-pack/test/cases_api_integration/common/config.ts +++ b/x-pack/test/cases_api_integration/common/config.ts @@ -22,6 +22,7 @@ interface CreateTestConfigOptions { } const enabledActionTypes = [ + '.cases', '.cases-webhook', '.email', '.index', diff --git a/x-pack/test/cases_api_integration/common/lib/api/connectors.ts b/x-pack/test/cases_api_integration/common/lib/api/connectors.ts index b5268191fddfe..0eb6854cb735d 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/connectors.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/connectors.ts @@ -11,7 +11,11 @@ import http from 'http'; import type SuperTest from 'supertest'; import { CASE_CONFIGURE_CONNECTORS_URL } from '@kbn/cases-plugin/common/constants'; import { getCaseConnectorsUrl } from '@kbn/cases-plugin/common/api'; -import { ActionResult, FindActionResult } from '@kbn/actions-plugin/server/types'; +import { + ActionResult, + ActionTypeExecutorResult, + FindActionResult, +} from '@kbn/actions-plugin/server/types'; import { getServiceNowServer } from '@kbn/actions-simulators-plugin/server/plugin'; import { RecordingServiceNowSimulator } from '@kbn/actions-simulators-plugin/server/servicenow_simulation'; import { @@ -21,6 +25,7 @@ import { ConnectorTypes, } from '@kbn/cases-plugin/common/types/domain'; import { CasePostRequest, GetCaseConnectorsResponse } from '@kbn/cases-plugin/common/types/api'; +import { camelCase, mapKeys } from 'lodash'; import { User } from '../authentication/types'; import { superUser } from '../authentication/users'; import { getPostCaseRequest } from '../mock'; @@ -316,3 +321,49 @@ export const getConnectors = async ({ return connectors; }; + +export const executeConnector = async ({ + supertest, + connectorId, + req, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest; + connectorId: string; + req: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise> => { + const { body: res } = await supertest + .post(`${getSpaceUrlPrefix(auth.space)}/api/actions/connector/${connectorId}/_execute`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send(req) + .expect(expectedHttpCode); + + return mapKeys(res, (_v, k) => camelCase(k)) as ActionTypeExecutorResult; +}; + +export const executeSystemConnector = async ({ + supertest, + connectorId, + req, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest; + connectorId: string; + req: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise> => { + const { body: res } = await supertest + .post(`${getSpaceUrlPrefix(auth.space)}/api/cases_fixture/${connectorId}/connectors:execute`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send(req) + .expect(expectedHttpCode); + + return mapKeys(res, (_v, k) => camelCase(k)) as ActionTypeExecutorResult; +}; diff --git a/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts b/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts index 85c33bc9517f3..d5969606dc414 100644 --- a/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts +++ b/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts @@ -22,7 +22,7 @@ export const noKibanaPrivileges: Role = { }; export const noCasesPrivilegesSpace1: Role = { - name: 'no_kibana_privileges', + name: 'no_cases_kibana_privileges', privileges: { elasticsearch: { indices: [ @@ -374,6 +374,29 @@ export const securitySolutionOnlyAllSpacesRole: Role = { }, }; +export const onlyActions: Role = { + name: 'only_actions', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + export const roles = [ noKibanaPrivileges, noCasesPrivilegesSpace1, @@ -390,4 +413,5 @@ export const roles = [ observabilityOnlyReadAlerts, testDisabledPluginAll, securitySolutionOnlyReadNoIndexAlerts, + onlyActions, ]; diff --git a/x-pack/test/cases_api_integration/common/lib/authentication/users.ts b/x-pack/test/cases_api_integration/common/lib/authentication/users.ts index a4b7828d74b9e..9bf90665eb181 100644 --- a/x-pack/test/cases_api_integration/common/lib/authentication/users.ts +++ b/x-pack/test/cases_api_integration/common/lib/authentication/users.ts @@ -22,6 +22,7 @@ import { securitySolutionOnlyReadNoIndexAlerts, securitySolutionOnlyReadDelete, noCasesConnectors as noCasesConnectorRole, + onlyActions as onlyActionsRole, } from './roles'; import { User } from './types'; @@ -122,8 +123,8 @@ export const noKibanaPrivileges: User = { }; export const noCasesPrivilegesSpace1: User = { - username: 'no_kibana_privileges_space1', - password: 'no_kibana_privileges_space1', + username: 'no_cases_privileges_space1', + password: 'no_cases_privileges_space1', roles: [noCasesPrivilegesSpace1Role.name], }; @@ -143,6 +144,12 @@ export const secOnlySpacesAll: User = { roles: [securitySolutionOnlyAllSpacesRole.name], }; +export const onlyActions: User = { + username: 'only_actions', + password: 'only_actions', + roles: [onlyActionsRole.name], +}; + export const users = [ superUser, secOnly, @@ -162,4 +169,5 @@ export const users = [ noCasesPrivilegesSpace1, testDisabled, noCasesConnectors, + onlyActions, ]; diff --git a/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc b/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc index 3989d35f8a2aa..135db481efeef 100644 --- a/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc +++ b/x-pack/test/cases_api_integration/common/plugins/cases/kibana.jsonc @@ -7,6 +7,7 @@ "server": true, "browser": false, "requiredPlugins": [ + "actions", "features", "cases", "files", diff --git a/x-pack/test/cases_api_integration/common/plugins/cases/server/plugin.ts b/x-pack/test/cases_api_integration/common/plugins/cases/server/plugin.ts index 9de0d8b2a8cef..8d5ee1660d1fd 100644 --- a/x-pack/test/cases_api_integration/common/plugins/cases/server/plugin.ts +++ b/x-pack/test/cases_api_integration/common/plugins/cases/server/plugin.ts @@ -11,6 +11,7 @@ import { SpacesPluginStart } from '@kbn/spaces-plugin/server'; import { SecurityPluginStart } from '@kbn/security-plugin/server'; import type { CasesServerStart, CasesServerSetup } from '@kbn/cases-plugin/server'; import { FilesSetup } from '@kbn/files-plugin/server'; +import { PluginStartContract as ActionsPluginsStart } from '@kbn/actions-plugin/server/plugin'; import { getPersistableStateAttachment } from './attachments/persistable_state'; import { getExternalReferenceAttachment } from './attachments/external_reference'; import { registerRoutes } from './routes'; @@ -23,6 +24,7 @@ export interface FixtureSetupDeps { } export interface FixtureStartDeps { + actions: ActionsPluginsStart; security?: SecurityPluginStart; spaces?: SpacesPluginStart; cases: CasesServerStart; diff --git a/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts b/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts index 30dc4da6bb324..11335c4d7adc7 100644 --- a/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts +++ b/x-pack/test/cases_api_integration/common/plugins/cases/server/routes.ts @@ -14,6 +14,7 @@ import type { PersistableStateAttachmentTypeSetup, } from '@kbn/cases-plugin/server/attachment_framework/types'; import { BulkCreateCasesRequest, CasesPatchRequest } from '@kbn/cases-plugin/common/types/api'; +import { ActionExecutionSourceType } from '@kbn/actions-plugin/server/types'; import type { FixtureStartDeps } from './plugin'; const hashParts = (parts: string[]): string => { @@ -49,7 +50,7 @@ export const registerRoutes = (core: CoreSetup, logger: Logger const client = await cases.getCasesClientWithRequest(request); return response.ok({ - body: await client.cases.update(request.body as CasesPatchRequest), + body: await client.cases.bulkUpdate(request.body as CasesPatchRequest), }); } catch (error) { logger.error(`CasesClientUser failure: ${error}`); @@ -138,4 +139,43 @@ export const registerRoutes = (core: CoreSetup, logger: Logger } } ); + + router.post( + { + path: '/api/cases_fixture/{id}/connectors:execute', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + params: schema.recordOf(schema.string(), schema.any()), + }), + }, + }, + async (context, req, res) => { + const [_, { actions }] = await core.getStartServices(); + + const actionsClient = await actions.getActionsClientWithRequest(req); + + try { + return res.ok({ + body: await actionsClient.execute({ + actionId: req.params.id, + params: req.body.params, + source: { + type: ActionExecutionSourceType.HTTP_REQUEST, + source: req, + }, + relatedSavedObjects: [], + }), + }); + } catch (err) { + if (err.isBoom && err.output.statusCode === 403) { + return res.forbidden({ body: err }); + } + + throw err; + } + } + ); }; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts new file mode 100644 index 0000000000000..1e38e803ca18e --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts @@ -0,0 +1,1384 @@ +/* + * 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 type SuperTest from 'supertest'; +import { createHash } from 'node:crypto'; +import stringify from 'json-stable-stringify'; +import { + CasesConnectorRunParams, + OracleRecordAttributes, +} from '@kbn/cases-plugin/server/connectors/cases/types'; +import { AttachmentType, CasePostRequest } from '@kbn/cases-plugin/common'; +import { + AlertAttachment, + Attachments, + Case, + CaseStatuses, + CaseSeverity, + ConnectorTypes, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; +import { KbnClient } from '@kbn/test'; +import { CasePersistedAttributes } from '@kbn/cases-plugin/server/common/types/case'; +import { + SEVERITY_EXTERNAL_TO_ESMODEL, + STATUS_EXTERNAL_TO_ESMODEL, +} from '@kbn/cases-plugin/server/common/constants'; +import { Client } from '@elastic/elasticsearch'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { CASE_RULES_SAVED_OBJECT } from '@kbn/cases-plugin/common/constants'; +import { User } from '../../../../../common/lib/authentication/types'; +import { + globalRead, + noKibanaPrivileges, + onlyActions, +} from '../../../../../common/lib/authentication/users'; +import { + deleteAllCaseItems, + executeSystemConnector, + findCases, + getAllComments, + updateCase, + getCase, + getConfigurationRequest, + createConfiguration, + createComment, +} from '../../../../../common/lib/api'; +import { getPostCaseRequest, postCommentAlertReq } from '../../../../../common/lib/mock'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { roles as api_int_roles } from '../../../../../../api_integration/apis/cases/common/roles'; +import { + casesAllUser, + obsCasesAllUser, + obsCasesReadUser, + obsSecCasesAllUser, + obsSecCasesReadUser, + secAllCasesReadUser, + secAllSpace1User, + secAllUser, + users as api_int_users, +} from '../../../../../../api_integration/apis/cases/common/users'; +import { createUsersAndRoles, deleteUsersAndRoles } from '../../../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const kibanaServer = getService('kibanaServer'); + + describe('Case connector', () => { + const connectorId = 'system-connector-.cases'; + + afterEach(async () => { + await deleteAllCaseItems(es); + await clearOracleRecords(es, kibanaServer); + }); + + describe('validation', () => { + it('returns 400 if the alerts do not contain _id and _index', async () => { + const res = await executeSystemConnector({ + supertest, + connectorId, + // @ts-expect-error: need to test schema validation + req: getRequest({ alerts: [{ foo: 'bar' }] }), + }); + + expect(res.status).to.be('error'); + expect(res.serviceMessage).to.be( + 'Request validation failed (Error: [alerts.0]: Alert ID and index must be defined)' + ); + }); + + it('returns 400 when groupingBy has more than one value', async () => { + const res = await executeSystemConnector({ + supertest, + connectorId, + req: getRequest({ groupingBy: ['host.name', 'source.ip'] }), + }); + + expect(res.status).to.be('error'); + expect(res.serviceMessage).to.be( + 'Request validation failed (Error: [groupingBy]: array size is [2], but cannot be greater than [1])' + ); + }); + + it('returns 400 when timeWindow is invalid', async () => { + const res = await executeSystemConnector({ + supertest, + connectorId, + req: getRequest({ timeWindow: 'not-valid' }), + }); + + expect(res.status).to.be('error'); + expect(res.serviceMessage).to.be( + 'Request validation failed (Error: [timeWindow]: Not a valid time window)' + ); + }); + + it('returns 400 for valid date math but not valid time window', async () => { + const res = await executeSystemConnector({ + supertest, + connectorId, + req: getRequest({ timeWindow: '10d+3d' }), + }); + + expect(res.status).to.be('error'); + expect(res.serviceMessage).to.be( + 'Request validation failed (Error: [timeWindow]: Not a valid time window)' + ); + }); + + it('returns 400 for unsupported time units', async () => { + for (const unit of ['s', 'm', 'H', 'h']) { + const res = await executeSystemConnector({ + supertest, + connectorId, + req: getRequest({ timeWindow: `5${unit}` }), + }); + + expect(res.status).to.be('error'); + expect(res.serviceMessage).to.be( + 'Request validation failed (Error: [timeWindow]: Not a valid time window)' + ); + } + }); + + it('returns 400 when maximumCasesToOpen > 10', async () => { + const res = await executeSystemConnector({ + supertest, + connectorId, + req: getRequest({ maximumCasesToOpen: 11 }), + }); + + expect(res.status).to.be('error'); + expect(res.serviceMessage).to.be( + 'Request validation failed (Error: [maximumCasesToOpen]: Value must be equal to or lower than [10].)' + ); + }); + + it('returns 400 when maximumCasesToOpen < 1', async () => { + const res = await executeSystemConnector({ + supertest, + connectorId, + req: getRequest({ maximumCasesToOpen: 0 }), + }); + + expect(res.status).to.be('error'); + expect(res.serviceMessage).to.be( + 'Request validation failed (Error: [maximumCasesToOpen]: Value must be equal to or greater than [1].)' + ); + }); + }); + + describe('execution', () => { + describe('Without grouping', () => { + const req = getRequest(); + + it('should create an oracle record correctly', async () => { + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req, + }); + + const oracleRecord = await getOracleRecord({ + kibanaServer, + oracleId: generateOracleId({ ruleId: req.params.subActionParams.rule.id }), + }); + + expect(oracleRecord.counter).to.be(1); + expect(oracleRecord.rules[0]).to.eql({ id: req.params.subActionParams.rule.id }); + }); + + it('should increase the oracle counter when the case is closed', async () => { + const reqClosedCase = getRequest({ reopenClosedCases: false }); + + const theCase = await createCaseWithId({ + kibanaServer, + caseId: generateCaseId({ ruleId: reqClosedCase.params.subActionParams.rule.id }), + }); + + const closedCase = await updateCase({ + supertest, + params: { + cases: [ + { id: theCase.id, version: theCase.version ?? '', status: CaseStatuses.closed }, + ], + }, + }); + + expect(closedCase[0].status).to.be('closed'); + + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req: reqClosedCase, + }); + + const oracleRecord = await getOracleRecord({ + kibanaServer, + oracleId: generateOracleId({ ruleId: req.params.subActionParams.rule.id }), + }); + + expect(oracleRecord.counter).to.be(2); + }); + + it('should increase the counter when the time window has elapsed', async () => { + const reqTimeWindow = getRequest({ timeWindow: '7d' }); + const oracleId = generateOracleId({ ruleId: req.params.subActionParams.rule.id }); + + await createOracleRecord({ + es, + oracleId, + attributes: { date: '2024-02-10T11:00:00.000Z' }, + }); + + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req: reqTimeWindow, + }); + + const oracleRecord = await getOracleRecord({ + kibanaServer, + oracleId, + }); + + expect(oracleRecord.counter).to.be(2); + }); + + it('should not create another oracle record if it exists', async () => { + const oracleId = generateOracleId({ ruleId: req.params.subActionParams.rule.id }); + + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req, + }); + + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req, + }); + + const records = await getAllOracleRecords({ + kibanaServer, + }); + + expect(records.total).to.be(1); + expect(records.records[0].id).to.eql(oracleId); + + return true; + }); + + it('should create a case correctly', async () => { + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + }); + + it('should create a case with the correct ID', async () => { + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + + const theCase = cases.cases[0]; + + expect(theCase.id).to.be(generateCaseId({ ruleId: req.params.subActionParams.rule.id })); + }); + + it('should create a case with the correct attributes', async () => { + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + + const theCase = removeServerGeneratedData(cases.cases[0]); + + expect(theCase).to.eql({ + assignees: [], + category: null, + closed_at: null, + closed_by: null, + comments: [], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + created_by: { + email: null, + full_name: null, + username: 'elastic', + }, + customFields: [], + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping:', + duration: null, + external_service: null, + id: 'ee06877e50151293e75cd6c5bd81812c15c25be55ed970f91c6f7dc40e1eafa6', + owner: 'securitySolutionFixture', + settings: { + syncAlerts: false, + }, + severity: 'low', + status: 'open', + tags: ['auto-generated', 'rule:rule-test-id', 'rule', 'test'], + title: 'Test rule (Auto-created)', + totalAlerts: 5, + totalComment: 0, + updated_by: { + email: null, + full_name: null, + username: 'elastic', + }, + }); + }); + + it('should reopen a closed case', async () => { + const reqClosedCase = getRequest({ reopenClosedCases: true }); + + const theCase = await createCaseWithId({ + kibanaServer, + caseId: generateCaseId({ ruleId: reqClosedCase.params.subActionParams.rule.id }), + }); + + const closedCase = await updateCase({ + supertest, + params: { + cases: [ + { id: theCase.id, version: theCase.version ?? '', status: CaseStatuses.closed }, + ], + }, + }); + + expect(closedCase[0].status).to.be('closed'); + + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req: reqClosedCase, + }); + + const updatedCase = await getCase({ supertest, caseId: theCase.id }); + expect(updatedCase.status).to.be('open'); + }); + + it('should create an new case when the time window has elapsed and attach alerts correctly', async () => { + const reqTimeWindow = getRequest({ timeWindow: '7d' }); + const oracleId = generateOracleId({ ruleId: req.params.subActionParams.rule.id }); + + await createOracleRecord({ + es, + oracleId, + attributes: { date: '2024-02-10T11:00:00.000Z' }, + }); + + const theCase = await createCaseWithId({ + kibanaServer, + caseId: generateCaseId({ ruleId: reqTimeWindow.params.subActionParams.rule.id }), + }); + + const closedCase = await updateCase({ + supertest, + params: { + cases: [ + { id: theCase.id, version: theCase.version ?? '', status: CaseStatuses.closed }, + ], + }, + }); + + expect(closedCase[0].status).to.be('closed'); + + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req: reqTimeWindow, + }); + + const oldCase = await getCase({ supertest, caseId: theCase.id }); + + expect(oldCase.status).to.be('closed'); + + const newCase = await getCase({ + supertest, + caseId: generateCaseId({ + ruleId: reqTimeWindow.params.subActionParams.rule.id, + counter: 2, + }), + }); + + expect(newCase.status).to.be('open'); + + const attachments = await getAllComments({ supertest, caseId: newCase.id }); + + verifyAlertsAttachedToCase({ + caseAttachments: attachments, + expectedAlertIdsToBeAttachedToCase: new Set( + req.params.subActionParams.alerts.map((alert) => alert._id) + ), + rule: { + id: req.params.subActionParams.rule.id, + name: req.params.subActionParams.rule.name, + }, + }); + }); + + it('should not create a new case if it exists', async () => { + const theCase = await createCaseWithId({ + kibanaServer, + caseId: generateCaseId({ ruleId: req.params.subActionParams.rule.id }), + }); + + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + + expect(theCase.id).to.be(cases.cases[0].id); + }); + + it('should create the correct case if the oracle record exists', async () => { + const oracleId = generateOracleId({ + ruleId: req.params.subActionParams.rule.id, + }); + + await createOracleRecord({ es, oracleId, attributes: { counter: 3 } }); + const caseId = generateCaseId({ ruleId: req.params.subActionParams.rule.id, counter: 3 }); + + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + + expect(caseId).to.be(cases.cases[0].id); + }); + + it('should attach the correct number of alerts', async () => { + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + + const theCase = cases.cases[0]; + + expect(theCase.totalAlerts).to.be(req.params.subActionParams.alerts.length); + }); + + it('should attach the correct alerts', async () => { + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + + const theCase = cases.cases[0]; + + const attachments = await getAllComments({ supertest, caseId: theCase.id }); + + verifyAlertsAttachedToCase({ + caseAttachments: attachments, + expectedAlertIdsToBeAttachedToCase: new Set( + req.params.subActionParams.alerts.map((alert) => alert._id) + ), + rule: { + id: req.params.subActionParams.rule.id, + name: req.params.subActionParams.rule.name, + }, + }); + }); + + it('create case with custom fields correctly', async () => { + const customFields = { + customFields: [ + { key: 'text_1', label: 'text 1', type: CustomFieldTypes.TEXT, required: true }, + ], + }; + + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: customFields, + }) + ); + + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + expect(cases.cases[0].customFields).to.eql([ + { key: 'text_1', type: CustomFieldTypes.TEXT, value: 'N/A' }, + ]); + }); + + it('should add more alerts to the same case', async () => { + const alerts = Array.from(Array(5).keys()).map((index) => ({ + _id: `alert-id-new-${index}`, + _index: 'alert-index-0', + })); + + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req: getRequest({ alerts }), + }); + + const theCase = cases.cases[0]; + + const attachments = await getAllComments({ supertest, caseId: theCase.id }); + + verifyAlertsAttachedToCase({ + caseAttachments: attachments, + expectedAlertIdsToBeAttachedToCase: new Set([ + ...req.params.subActionParams.alerts.map((alert) => alert._id), + ...alerts.map((alert) => alert._id), + ]), + rule: { + id: req.params.subActionParams.rule.id, + name: req.params.subActionParams.rule.name, + }, + }); + }); + + it('should attach the alerts to an existing case', async () => { + const theCase = await createCaseWithId({ + kibanaServer, + caseId: generateCaseId({ ruleId: req.params.subActionParams.rule.id }), + }); + + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + + const attachments = await getAllComments({ supertest, caseId: theCase.id }); + + verifyAlertsAttachedToCase({ + caseAttachments: attachments, + expectedAlertIdsToBeAttachedToCase: new Set([ + ...req.params.subActionParams.alerts.map((alert) => alert._id), + ]), + rule: { + id: req.params.subActionParams.rule.id, + name: req.params.subActionParams.rule.name, + }, + }); + }); + + it('should not attach alerts to a case with more that 1K alerts and should not throw', async () => { + const alerts = [...Array(1000).keys()].map((num) => `test-${num}`); + + const theCase = await createCaseWithId({ + kibanaServer, + caseId: generateCaseId({ ruleId: req.params.subActionParams.rule.id }), + }); + + await createComment({ + supertest, + caseId: theCase.id, + params: { ...postCommentAlertReq, alertId: alerts, index: alerts }, + }); + + await executeConnectorAndVerifyCorrectness({ supertest, connectorId, req }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + + const attachments = await getAllComments({ supertest, caseId: theCase.id }); + expect(attachments.length).to.be(1); + expect((attachments[0] as AlertAttachment).alertId.length).to.be(1000); + }); + }); + + describe('With grouping', () => { + const req = getRequest({ groupingBy: ['host.name'] }); + + describe('Oracle', () => { + it('should create the oracle records correctly with grouping', async () => { + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req, + }); + + const firstOracleId = generateOracleId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'A' }, + }); + + const secondOracleId = generateOracleId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'B' }, + }); + + const firstOracleRecord = await getOracleRecord({ + kibanaServer, + oracleId: firstOracleId, + }); + + const secondOracleRecord = await getOracleRecord({ + kibanaServer, + oracleId: secondOracleId, + }); + + expect(firstOracleRecord.counter).to.be(1); + expect(firstOracleRecord.rules[0]).to.eql({ + id: req.params.subActionParams.rule.id, + }); + + expect(secondOracleRecord.counter).to.be(1); + expect(secondOracleRecord.rules[0]).to.eql({ + id: req.params.subActionParams.rule.id, + }); + }); + }); + + describe('Cases & alerts', () => { + it('should create cases correctly', async () => { + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req, + }); + + const cases = await findCases({ supertest }); + + expect(cases.total).to.be(2); + + const firstCaseId = generateCaseId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'A' }, + }); + const secondCaseId = generateCaseId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'B' }, + }); + + const firstCase = removeServerGeneratedData( + cases.cases.find((theCase) => theCase.id === firstCaseId)! + ); + + const secondCase = removeServerGeneratedData( + cases.cases.find((theCase) => theCase.id === secondCaseId)! + ); + + expect(firstCase).to.eql({ + assignees: [], + category: null, + closed_at: null, + closed_by: null, + comments: [], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + created_by: { + email: null, + full_name: null, + username: 'elastic', + }, + customFields: [], + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `A`', + duration: null, + external_service: null, + id: firstCaseId, + owner: 'securitySolutionFixture', + settings: { + syncAlerts: false, + }, + severity: 'low', + status: 'open', + tags: ['auto-generated', 'rule:rule-test-id', 'host.name:A', 'rule', 'test'], + title: 'Test rule (Auto-created)', + totalAlerts: 3, + totalComment: 0, + updated_by: { + email: null, + full_name: null, + username: 'elastic', + }, + }); + + expect(secondCase).to.eql({ + assignees: [], + category: null, + closed_at: null, + closed_by: null, + comments: [], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + created_by: { + email: null, + full_name: null, + username: 'elastic', + }, + customFields: [], + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `B`', + duration: null, + external_service: null, + id: secondCaseId, + owner: 'securitySolutionFixture', + settings: { + syncAlerts: false, + }, + severity: 'low', + status: 'open', + tags: ['auto-generated', 'rule:rule-test-id', 'host.name:B', 'rule', 'test'], + title: 'Test rule (Auto-created)', + totalAlerts: 2, + totalComment: 0, + updated_by: { + email: null, + full_name: null, + username: 'elastic', + }, + }); + }); + + it('fallback to one case when a lot of cases will be generated', async () => { + const alerts = Array.from(Array(10).keys()).map((index) => ({ + _id: `alert-id-${index}`, + _index: 'alert-index-0', + 'host.name': index, + })); + + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req: getRequest({ groupingBy: ['host.name'], alerts }), + }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(1); + }); + + it('should attach the alerts to the correct cases', async () => { + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req, + }); + + const cases = await findCases({ supertest }); + + expect(cases.total).to.be(2); + + const firstCaseId = generateCaseId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'A' }, + }); + const secondCaseId = generateCaseId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'B' }, + }); + + const firstCase = removeServerGeneratedData( + cases.cases.find((theCase) => theCase.id === firstCaseId)! + ); + + const firstCaseAttachments = await getAllComments({ supertest, caseId: firstCase.id }); + + verifyAlertsAttachedToCase({ + caseAttachments: firstCaseAttachments, + expectedAlertIdsToBeAttachedToCase: new Set([ + 'alert-id-0', + 'alert-id-2', + 'alert-id-4', + ]), + rule: { + id: req.params.subActionParams.rule.id, + name: req.params.subActionParams.rule.name, + }, + }); + + const secondCase = removeServerGeneratedData( + cases.cases.find((theCase) => theCase.id === secondCaseId)! + ); + + const secondCaseAttachments = await getAllComments({ + supertest, + caseId: secondCase.id, + }); + + verifyAlertsAttachedToCase({ + caseAttachments: secondCaseAttachments, + expectedAlertIdsToBeAttachedToCase: new Set(['alert-id-1', 'alert-id-3']), + rule: { + id: req.params.subActionParams.rule.id, + name: req.params.subActionParams.rule.name, + }, + }); + }); + + it('should add more alerts to the same case', async () => { + const hostAAlerts = Array.from(Array(3).keys()).map((index) => ({ + _id: `alert-id-host-A-${index}`, + _index: 'alert-index-0', + 'host.name': 'A', + })); + + const hostBAlerts = Array.from(Array(3).keys()).map((index) => ({ + _id: `alert-id-host-B-${index}`, + _index: 'alert-index-0', + 'host.name': 'B', + })); + + const totalAlerts = [...hostAAlerts, ...hostBAlerts]; + + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req: getRequest({ + alerts: req.params.subActionParams.alerts, + groupingBy: req.params.subActionParams.groupingBy, + }), + }); + + const cases = await findCases({ supertest }); + expect(cases.total).to.be(2); + + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req: getRequest({ + alerts: totalAlerts, + groupingBy: req.params.subActionParams.groupingBy, + }), + }); + + const firstCaseId = generateCaseId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'A' }, + }); + + const secondCaseId = generateCaseId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'B' }, + }); + + const firstCase = removeServerGeneratedData( + cases.cases.find((theCase) => theCase.id === firstCaseId)! + ); + + const secondCase = removeServerGeneratedData( + cases.cases.find((theCase) => theCase.id === secondCaseId)! + ); + + const firstCaseAttachments = await getAllComments({ + supertest, + caseId: firstCase.id, + }); + + const secondCaseAttachments = await getAllComments({ + supertest, + caseId: secondCase.id, + }); + + verifyAlertsAttachedToCase({ + caseAttachments: firstCaseAttachments, + expectedAlertIdsToBeAttachedToCase: new Set([ + ...['alert-id-0', 'alert-id-2', 'alert-id-4'], + ...hostAAlerts.map((alert) => alert._id), + ]), + rule: { + id: req.params.subActionParams.rule.id, + name: req.params.subActionParams.rule.name, + }, + }); + + verifyAlertsAttachedToCase({ + caseAttachments: secondCaseAttachments, + expectedAlertIdsToBeAttachedToCase: new Set([ + ...['alert-id-1', 'alert-id-3'], + ...hostBAlerts.map((alert) => alert._id), + ]), + rule: { + id: req.params.subActionParams.rule.id, + name: req.params.subActionParams.rule.name, + }, + }); + }); + }); + + describe('Non grouped alerts', () => { + it('should attach non grouped alerts correctly', async () => { + await executeConnectorAndVerifyCorrectness({ + supertest, + connectorId, + req: getRequest({ + alerts: [ + { _id: 'alert-id-0', _index: 'alert-index-0', 'host.name': 'A' }, + { _id: 'alert-id-1', _index: 'alert-index-1', 'dest.ip': '0.0.0.1' }, + { _id: `alert-id-2`, _index: 'alert-index-2' }, + ], + groupingBy: ['host.name'], + }), + }); + + const cases = await findCases({ supertest }); + + expect(cases.total).to.be(2); + + const firstOracleId = generateOracleId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'A' }, + }); + + const secondOracleId = generateOracleId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'unknown' }, + }); + + const firstOracleRecord = await getOracleRecord({ + kibanaServer, + oracleId: firstOracleId, + }); + + const secondOracleRecord = await getOracleRecord({ + kibanaServer, + oracleId: secondOracleId, + }); + + expect(firstOracleRecord.grouping).to.eql({ 'host.name': 'A' }); + expect(secondOracleRecord.grouping).to.eql({ 'host.name': 'unknown' }); + + const firstCaseId = generateCaseId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'A' }, + }); + + const secondCaseId = generateCaseId({ + ruleId: req.params.subActionParams.rule.id, + grouping: { 'host.name': 'unknown' }, + }); + + const firstCase = removeServerGeneratedData( + cases.cases.find((theCase) => theCase.id === firstCaseId)! + ); + + const secondCase = removeServerGeneratedData( + cases.cases.find((theCase) => theCase.id === secondCaseId)! + ); + + expect(firstCase.description).to.be( + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `A`' + ); + expect(secondCase.description).to.be( + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `unknown`' + ); + + const firstCaseAttachments = await getAllComments({ + supertest, + caseId: firstCase.id, + }); + + const secondCaseAttachments = await getAllComments({ + supertest, + caseId: secondCase.id, + }); + + verifyAlertsAttachedToCase({ + caseAttachments: firstCaseAttachments, + expectedAlertIdsToBeAttachedToCase: new Set(['alert-id-0']), + rule: { + id: req.params.subActionParams.rule.id, + name: req.params.subActionParams.rule.name, + }, + }); + + verifyAlertsAttachedToCase({ + caseAttachments: secondCaseAttachments, + expectedAlertIdsToBeAttachedToCase: new Set(['alert-id-1', 'alert-id-2']), + rule: { + id: req.params.subActionParams.rule.id, + name: req.params.subActionParams.rule.name, + }, + }); + }); + }); + }); + }); + + describe('rbac', () => { + before(async () => { + await createUsersAndRoles(getService, api_int_users, api_int_roles); + }); + + after(async () => { + await deleteUsersAndRoles(getService, api_int_users, api_int_roles); + }); + + it('should not execute without permission to cases for all owners', async () => { + for (const owner of ['cases', 'securitySolution', 'observability']) { + const req = getRequest({ owner }); + await executeSystemConnector({ + supertest: supertestWithoutAuth, + connectorId, + req, + auth: { user: onlyActions, space: null }, + expectedHttpCode: 403, + }); + } + }); + + it('should not execute in a space with no permissions', async () => { + const req = getRequest({ owner: 'securitySolution' }); + await executeSystemConnector({ + supertest: supertestWithoutAuth, + connectorId, + req, + auth: { user: secAllSpace1User, space: 'space2' }, + expectedHttpCode: 403, + }); + }); + + it('should not execute with read permission to cases', async () => { + for (const user of [ + globalRead, + secAllCasesReadUser, + obsCasesReadUser, + obsSecCasesReadUser, + noKibanaPrivileges, + ]) { + const req = getRequest({ owner: 'securitySolution' }); + await executeSystemConnector({ + supertest: supertestWithoutAuth, + connectorId, + req, + auth: { user, space: null }, + expectedHttpCode: 403, + }); + } + }); + + it('should execute correctly for users with permissions to cases', async () => { + const usersToTest: Array<[User, string]> = [ + [secAllUser, 'securitySolution'], + [obsCasesAllUser, 'observability'], + [casesAllUser, 'cases'], + [obsSecCasesAllUser, 'securitySolution'], + [obsSecCasesAllUser, 'observability'], + ]; + + for (const [user, owner] of usersToTest) { + const req = getRequest({ owner }); + const res = await executeSystemConnector({ + supertest: supertestWithoutAuth, + connectorId, + req, + auth: { user, space: null }, + expectedHttpCode: 200, + }); + + expect(res.status).to.be('ok'); + } + }); + + it('should not execute when users have permission to cases but for different owners', async () => { + const usersToTest: Array<[User, string]> = [ + [secAllUser, 'observability'], + [obsCasesAllUser, 'securitySolution'], + [casesAllUser, 'securitySolution'], + [obsSecCasesAllUser, 'cases'], + [obsSecCasesAllUser, 'cases'], + ]; + + for (const [user, owner] of usersToTest) { + const req = getRequest({ owner }); + await executeSystemConnector({ + supertest: supertestWithoutAuth, + connectorId, + req, + auth: { user, space: null }, + expectedHttpCode: 403, + }); + } + }); + }); + }); +}; + +const getRequest = (params: Partial = {}) => { + const alerts = [ + { + _id: 'alert-id-0', + _index: 'alert-index-0', + 'host.name': 'A', + 'dest.ip': '0.0.0.1', + 'source.ip': '0.0.0.2', + }, + { + _id: 'alert-id-1', + _index: 'alert-index-1', + 'host.name': 'B', + 'dest.ip': '0.0.0.1', + 'file.hash': '12345', + }, + { _id: 'alert-id-2', _index: 'alert-index-2', 'host.name': 'A', 'dest.ip': '0.0.0.1' }, + { _id: 'alert-id-3', _index: 'alert-index-3', 'host.name': 'B', 'dest.ip': '0.0.0.3' }, + { _id: 'alert-id-4', _index: 'alert-index-4', 'host.name': 'A', 'source.ip': '0.0.0.5' }, + ]; + + const rule = { + id: 'rule-test-id', + name: 'Test rule', + tags: ['rule', 'test'], + ruleUrl: 'https://example.com/rules/rule-test-id', + }; + + const owner = 'securitySolutionFixture'; + const timeWindow = '7d'; + const reopenClosedCases = false; + + const req: { subAction: 'run'; subActionParams: CasesConnectorRunParams } = { + subAction: 'run', + subActionParams: { + alerts, + groupingBy: [], + rule, + owner, + timeWindow, + reopenClosedCases, + maximumCasesToOpen: 5, + ...params, + }, + }; + + return { params: req }; +}; + +const generateCaseId = ({ + ruleId, + grouping = {}, + counter = 1, + spaceId = 'default', + owner = 'securitySolutionFixture', +}: { + ruleId: string; + grouping?: Record; + counter?: number; + spaceId?: string; + owner?: string; +}) => { + return generateId({ ruleId, counter, grouping, spaceId, owner }); +}; + +const generateOracleId = ({ + ruleId, + grouping = {}, + spaceId = 'default', + owner = 'securitySolutionFixture', +}: { + ruleId: string; + grouping?: Record; + spaceId?: string; + owner?: string; +}) => { + return generateId({ ruleId, grouping, spaceId, owner }); +}; + +const generateId = ({ + ruleId, + grouping, + counter, + spaceId, + owner, +}: { + ruleId?: string; + grouping?: Record; + counter?: number; + spaceId?: string; + owner?: string; +}) => { + const payload = [ruleId, spaceId, owner, stringify(grouping), counter].filter(Boolean).join(':'); + const hash = createHash('sha256'); + + hash.update(payload); + return hash.digest('hex'); +}; + +const removeServerGeneratedData = ( + theCase: Case +): Omit => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { created_at, updated_at, version, ...restCase } = theCase; + + return restCase; +}; + +const verifyAlertsAttachedToCase = ({ + expectedAlertIdsToBeAttachedToCase, + caseAttachments, + rule, +}: { + expectedAlertIdsToBeAttachedToCase: Set; + caseAttachments: Attachments; + rule: { id: string; name: string }; +}) => { + const alertsAttachedToCase = caseAttachments.filter( + (attachment): attachment is AlertAttachment => attachment.type === AttachmentType.alert + ); + + const alertIdsAttachedToCase = new Set(alertsAttachedToCase.map((alert) => alert.alertId).flat()); + + expect(alertIdsAttachedToCase.size).to.be(expectedAlertIdsToBeAttachedToCase.size); + + for (const alert of alertsAttachedToCase) { + const alertIdAsArray = Array.isArray(alert.alertId) ? alert.alertId : [alert.alertId]; + expect( + alertIdAsArray.every((alertId) => expectedAlertIdsToBeAttachedToCase.has(alertId)) + ).to.be(true); + expect(alert.rule.id).to.be(rule.id); + expect(alert.rule.name).to.be(rule.name); + } +}; + +const createCaseWithId = async ({ + kibanaServer, + caseId, + req, +}: { + kibanaServer: KbnClient; + caseId: string; + req?: Partial; +}) => { + const res = await kibanaServer.savedObjects.create({ + id: caseId, + type: 'cases', + attributes: { + ...getPostCaseRequest(), + ...req, + assignees: [], + connector: { + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + // @ts-ignore + status: STATUS_EXTERNAL_TO_ESMODEL[req?.status ?? CaseStatuses.open], + // @ts-ignore + severity: SEVERITY_EXTERNAL_TO_ESMODEL[req?.severity ?? CaseSeverity.low], + closed_at: null, + closed_by: null, + updated_at: null, + updated_by: null, + created_at: new Date().toISOString(), + created_by: { username: 'elastic', full_name: null, email: null }, + duration: 0, + external_service: null, + total_alerts: 0, + total_comments: 0, + }, + overwrite: false, + }); + + return { id: res.id, version: res.version, ...res.attributes }; +}; + +const getOracleRecord = async ({ + kibanaServer, + oracleId, +}: { + kibanaServer: KbnClient; + oracleId: string; +}) => { + const res = await kibanaServer.savedObjects.get({ + id: oracleId, + type: CASE_RULES_SAVED_OBJECT, + }); + + return { id: res.id, version: res.version, ...res.attributes }; +}; + +const getAllOracleRecords = async ({ kibanaServer }: { kibanaServer: KbnClient }) => { + const res = await kibanaServer.savedObjects.find({ + type: CASE_RULES_SAVED_OBJECT, + }); + + return { + total: res.total, + records: res.saved_objects.map((so) => ({ id: so.id, version: so.version, ...so.attributes })), + }; +}; + +const clearOracleRecords = async (es: Client, kibanaServer: KbnClient) => { + await kibanaServer.savedObjects.clean({ types: [CASE_RULES_SAVED_OBJECT] }); + await es.deleteByQuery({ + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + q: 'type:cases-rules', + wait_for_completion: true, + refresh: true, + body: {}, + conflicts: 'proceed', + }); +}; + +const executeConnectorAndVerifyCorrectness = async ({ + supertest, + connectorId, + req, +}: { + supertest: SuperTest.SuperTest; + connectorId: string; + req: Record; +}) => { + const res = await executeSystemConnector({ supertest, connectorId, req }); + + expect(res.status).to.be('ok'); + + return res; +}; + +const createOracleRecord = async ({ + es, + oracleId, + attributes: { counter, date } = {}, +}: { + es: Client; + oracleId: string; + attributes?: { counter?: number; date?: string }; +}) => { + const creationDate = date ?? new Date().toISOString(); + + await es.create({ + id: `cases-rules:${oracleId}`, + index: ALERTING_CASES_SAVED_OBJECT_INDEX, + document: { + [CASE_RULES_SAVED_OBJECT]: { + createdAt: creationDate, + updatedAt: null, + cases: [], + grouping: {}, + rules: [{ id: 'rule-test-id' }], + counter: counter ?? 1, + }, + coreMigrationVersion: '8.8.0', + created_at: creationDate, + managed: false, + namespaces: ['default'], + type: CASE_RULES_SAVED_OBJECT, + typeMigrationVersion: '10.1.0', + references: [], + }, + }); +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts index f6ef4d3ede478..c1038eb964313 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts @@ -45,6 +45,9 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => { loadTestFile(require.resolve('./internal/user_actions_get_users')); loadTestFile(require.resolve('./internal/bulk_delete_file_attachments')); + // Connectors + loadTestFile(require.resolve('./connectors/cases/cases_connector')); + // Common loadTestFile(require.resolve('../common')); 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 c826ef8910ca3..1255f117329c9 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 @@ -52,6 +52,7 @@ export default function ({ getService }: FtrProviderContext) { 'Synthetics:Clean-Up-Package-Policies', 'UPTIME:SyntheticsService:Sync-Saved-Monitor-Objects', 'actions:.bedrock', + 'actions:.cases', 'actions:.cases-webhook', 'actions:.d3security', 'actions:.email',