From 43092adc4a010e7a681498499bffb44c2a93f226 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Mon, 7 Jun 2021 15:27:45 -0400 Subject: [PATCH] [7.x] [Cases] RBAC (#95058) (#101488) * [Cases] RBAC (#95058) * Adding feature flag for auth * Hiding SOs and adding consumer field * First pass at adding security changes * Consumer as the app's plugin ID * Create addConsumerToSO migration helper * Fix mapping's SO consumer * Add test for CasesActions * Declare hidden types on SO client * Restructure integration tests * Init spaces_only integration tests * Implementing the cases security string * Adding security plugin tests for cases * Rough concept for authorization class * Adding comments * Fix merge * Get requiredPrivileges for classes * Check privillages * Ensure that all classes are available * Success if hasAllRequested is true * Failure if hasAllRequested is false * Adding schema updates for feature plugin * Seperate basic from trial * Enable SIR on integration tests * Starting the plumbing for authorization in plugin * Unit tests working * Move find route logic to case client * Create integration test helper functions * Adding auth to create call * Create getClassFilter helper * Add class attribute to find request * Create getFindAuthorizationFilter * Ensure savedObject is authorized in find method * Include fields for authorization * Combine authorization filter with cases & subcases filter * Fix isAuthorized flag * Fix merge issue * Create/delete spaces & users before and after tests * Add more user and roles * [Cases] Convert filters from strings to KueryNode (#95288) * [Cases] RBAC: Rename class to scope (#95535) * [Cases][RBAC] Rename scope to owner (#96035) * [Cases] RBAC: Create & Find integration tests (#95511) * [Cases] Cases client enchantment (#95923) * [Cases] Authorization and Client Audit Logger (#95477) * Starting audit logger * Finishing auth audit logger * Fixing tests and types * Adding audit event creator * Renaming class to scope * Adding audit logger messages to create and find * Adding comments and fixing import issue * Fixing type errors * Fixing tests and adding username to message * Addressing PR feedback * Removing unneccessary log and generating id * Fixing module issue and remove expect.anything * [Cases] Migrate sub cases routes to a client (#96461) * Adding sub cases client * Move sub case routes to case client * Throw when attempting to access the sub cases client * Fixing throw and removing user ans soclients * [Cases] RBAC: Migrate routes' unit tests to integration tests (#96374) Co-authored-by: Jonathan Buttner * [Cases] Move remaining HTTP functionality to client (#96507) * Moving deletes and find for attachments * Moving rest of comment apis * Migrating configuration routes to client * Finished moving routes, starting utils refactor * Refactoring utilites and fixing integration tests * Addressing PR feedback * Fixing mocks and types * Fixing integration tests * Renaming status_stats * Fixing test type errors * Adding plugins to kibana.json * Adding cases to required plugin * [Cases] Refactoring authorization (#97483) * Refactoring authorization * Wrapping auth calls in helper for try catch * Reverting name change * Hardcoding the saved object types * Switching ensure to owner array * [Cases] Add authorization to configuration & cases routes (#97228) * [Cases] Attachments RBAC (#97756) * Starting rbac for comments * Adding authorization to rest of comment apis * Starting the comment rbac tests * Fixing some of the rbac tests * Adding some integration tests * Starting patch tests * Working tests for comments * Working tests * Fixing some tests * Fixing type issues from pulling in master * Fixing connector tests that only work in trial license * Attempting to fix cypress * Mock return of array for configure * Fixing cypress test * Cleaning up * Addressing PR comments * Reducing operations * [Cases] Add RBAC to remaining Cases APIs (#98762) * Starting rbac for comments * Adding authorization to rest of comment apis * Starting the comment rbac tests * Fixing some of the rbac tests * Adding some integration tests * Starting patch tests * Working tests for comments * Working tests * Fixing some tests * Fixing type issues from pulling in master * Fixing connector tests that only work in trial license * Attempting to fix cypress * Mock return of array for configure * Fixing cypress test * Cleaning up * Working case update tests * Addressing PR comments * Reducing operations * Working rbac push case tests * Starting stats apis * Working status tests * User action tests and fixing migration errors * Fixing type errors * including error in message * Addressing pr feedback * Fixing some type errors * [Cases] Add space only tests (#99409) * Starting spaces tests * Finishing space only tests * Refactoring createCaseWithConnector * Fixing spelling * Addressing PR feedback and creating alert tests * Fixing mocks * [Cases] Add security only tests (#99679) * Starting spaces tests * Finishing space only tests * Refactoring createCaseWithConnector * Fixing spelling * Addressing PR feedback and creating alert tests * Fixing mocks * Starting security only tests * Adding remainder security only tests * Using helper objects * Fixing type error for null space * Renaming utility variables * Refactoring users and roles for security only tests * Adding sub feature * [Cases] Cleaning up the services and TODOs (#99723) * Cleaning up the service intialization * Fixing type errors * Adding comments for the api * Working test for cases client * Fix type error * Adding generated docs * Adding more docs and cleaning up types * Cleaning up readme * More clean up and links * Changing some file names * Renaming docs * Integration tests for cases privs and fixes (#100038) * [Cases] RBAC on UI (#99478) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * Fixing case ids by alert id route call * [Cases] Fixing UI feature permissions and adding UI tests (#100074) * Integration tests for cases privs and fixes * Fixing ui cases permissions and adding tests * Adding test for collection failure and fixing jest * Renaming variables * Fixing type error * Adding some comments * Validate cases features * Fix new schema * Adding owner param for the status stats * Fix get case status tests * Adjusting permissions text and fixing status * Address PR feedback * Adding top level feature back * Fixing feature privileges * Renaming * Removing uneeded else * Fixing tests and adding cases merge tests * [Cases][Security Solution] Basic license security solution API tests (#100925) * Cleaning up the fixture plugins * Adding basic feature test * renaming to unsecuredSavedObjectsClient (#101215) * [Cases] RBAC Refactoring audit logging (#100952) * Refactoring audit logging * Adding unit tests for authorization classes * Addressing feedback and adding util tests * return undefined on empty array * fixing eslint * [Cases] Cleaning up RBAC integration tests (#101324) * Adding tests for space permissions * Adding tests for testing a disable feature Co-authored-by: Christos Nasikas Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> # Conflicts: # x-pack/plugins/cases/server/client/client.ts # x-pack/plugins/cases/server/client/mocks.ts # x-pack/plugins/cases/server/client/types.ts # x-pack/plugins/cases/server/index.ts # x-pack/plugins/cases/server/plugin.ts # x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts # x-pack/plugins/security_solution/server/endpoint/mocks.ts # x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts * Fixing type errors * Addressing plugin type errors --- x-pack/plugins/cases/README.md | 6 +- x-pack/plugins/cases/common/api/cases/case.ts | 121 +- .../plugins/cases/common/api/cases/comment.ts | 14 + .../cases/common/api/cases/configure.ts | 37 +- .../cases/common/api/cases/constants.ts | 36 + .../plugins/cases/common/api/cases/index.ts | 1 + .../plugins/cases/common/api/cases/status.ts | 9 + .../cases/common/api/cases/sub_case.ts | 33 + .../cases/common/api/cases/user_actions.ts | 4 + .../cases/common/api/connectors/mappings.ts | 1 + x-pack/plugins/cases/common/api/helpers.ts | 5 + .../plugins/cases/common/api/runtime_types.ts | 8 +- .../plugins/cases/common/api/saved_object.ts | 33 + x-pack/plugins/cases/common/constants.ts | 31 + x-pack/plugins/cases/common/ui/types.ts | 3 +- x-pack/plugins/cases/docs/README.md | 37 + .../docs/cases_client/cases_client_api.md | 22 + .../classes/client.casesclient.md | 178 ++ .../interfaces/attachments_add.addargs.md | 34 + ...attachments_client.attachmentssubclient.md | 147 ++ .../attachments_delete.deleteallargs.md | 34 + .../attachments_delete.deleteargs.md | 45 + .../interfaces/attachments_get.findargs.md | 51 + .../interfaces/attachments_get.getallargs.md | 45 + .../interfaces/attachments_get.getargs.md | 32 + .../attachments_update.updateargs.md | 45 + .../interfaces/cases_client.casessubclient.md | 189 ++ .../cases_get.caseidsbyalertidparams.md | 40 + .../interfaces/cases_get.getparams.md | 45 + .../interfaces/cases_push.pushparams.md | 34 + .../configure_client.configuresubclient.md | 84 + .../interfaces/stats_client.statssubclient.md | 32 + .../sub_cases_client.subcasesclient.md | 89 + ...typedoc_interfaces.iallcommentsresponse.md | 11 + .../typedoc_interfaces.icasepostrequest.md | 88 + .../typedoc_interfaces.icaseresponse.md | 228 +++ ...typedoc_interfaces.icasesconfigurepatch.md | 43 + ...pedoc_interfaces.icasesconfigurerequest.md | 43 + ...edoc_interfaces.icasesconfigureresponse.md | 123 ++ .../typedoc_interfaces.icasesfindrequest.md | 133 ++ .../typedoc_interfaces.icasesfindresponse.md | 79 + .../typedoc_interfaces.icasespatchrequest.md | 25 + .../typedoc_interfaces.icasesresponse.md | 11 + ...doc_interfaces.icaseuseractionsresponse.md | 11 + .../typedoc_interfaces.icommentsresponse.md | 52 + .../typedoc_interfaces.isubcaseresponse.md | 133 ++ ...ypedoc_interfaces.isubcasesfindresponse.md | 79 + .../typedoc_interfaces.isubcasesresponse.md | 11 + .../user_actions_client.useractionget.md | 34 + ...ser_actions_client.useractionssubclient.md | 31 + .../cases_client/modules/attachments_add.md | 9 + .../modules/attachments_client.md | 9 + .../modules/attachments_delete.md | 10 + .../cases_client/modules/attachments_get.md | 11 + .../modules/attachments_update.md | 9 + .../docs/cases_client/modules/cases_client.md | 9 + .../docs/cases_client/modules/cases_get.md | 53 + .../docs/cases_client/modules/cases_push.md | 9 + .../cases/docs/cases_client/modules/client.md | 9 + .../cases_client/modules/configure_client.md | 9 + .../docs/cases_client/modules/stats_client.md | 9 + .../cases_client/modules/sub_cases_client.md | 9 + .../modules/typedoc_interfaces.md | 26 + .../modules/user_actions_client.md | 10 + .../cases/docs/cases_client_typedoc.json | 25 + x-pack/plugins/cases/kibana.json | 2 +- .../cases/public/common/lib/kibana/hooks.ts | 26 - .../public/common/mock/test_providers.tsx | 6 +- .../cases/public/common/translations.ts | 10 +- .../components/add_comment/index.test.tsx | 3 +- .../public/components/add_comment/index.tsx | 6 +- .../all_cases/all_cases_generic.test.tsx | 3 +- .../all_cases/all_cases_generic.tsx | 4 +- .../components/all_cases/index.test.tsx | 4 +- .../public/components/all_cases/index.tsx | 10 +- .../all_cases/selector_modal/index.test.tsx | 3 + .../all_cases/selector_modal/index.tsx | 14 +- .../public/components/callout/helpers.tsx | 4 +- .../public/components/callout/translations.ts | 13 +- .../components/case_action_bar/index.tsx | 1 + .../status_context_menu.test.tsx | 12 + .../case_action_bar/status_context_menu.tsx | 11 +- .../components/case_view/helpers.test.tsx | 4 +- .../public/components/case_view/index.tsx | 36 +- .../configure_cases/__mock__/index.tsx | 1 + .../components/configure_cases/button.tsx | 1 - .../components/configure_cases/index.test.tsx | 59 +- .../components/configure_cases/index.tsx | 15 +- .../connectors/case/alert_fields.tsx | 11 +- .../connectors/case/existing_case.tsx | 9 +- .../public/components/create/flyout.test.tsx | 115 -- .../cases/public/components/create/flyout.tsx | 71 - .../public/components/create/form.test.tsx | 8 +- .../components/create/form_context.test.tsx | 3 +- .../public/components/create/form_context.tsx | 10 +- .../public/components/create/index.test.tsx | 7 +- .../cases/public/components/create/index.tsx | 15 +- .../cases/public/components/create/mock.ts | 8 +- .../cases/public/components/create/schema.tsx | 2 +- .../public/components/create/tags.test.tsx | 8 +- .../public/components/owner_context/index.tsx | 18 + .../owner_context/use_owner_context.ts | 21 + .../components/recent_cases/index.test.tsx | 11 +- .../public/components/recent_cases/index.tsx | 16 +- .../components/recent_cases/recent_cases.tsx | 5 +- .../public/components/status/status.test.tsx | 24 + .../cases/public/components/status/status.tsx | 9 +- .../public/components/tag_list/index.test.tsx | 2 + .../public/components/tag_list/index.tsx | 3 +- .../create_case_modal.test.tsx | 2 + .../create_case_modal.tsx | 3 + .../use_create_case_modal/index.tsx | 5 +- .../cases/public/containers/__mocks__/api.ts | 1 + .../cases/public/containers/api.test.tsx | 32 +- x-pack/plugins/cases/public/containers/api.ts | 14 +- .../public/containers/configure/api.test.ts | 25 +- .../cases/public/containers/configure/api.ts | 32 +- .../cases/public/containers/configure/mock.ts | 6 + .../public/containers/configure/types.ts | 2 + .../configure/use_configure.test.tsx | 88 +- .../containers/configure/use_configure.tsx | 49 +- .../plugins/cases/public/containers/mock.ts | 9 + .../public/containers/use_get_cases.test.tsx | 70 +- .../cases/public/containers/use_get_cases.tsx | 18 +- .../containers/use_get_cases_status.test.tsx | 33 +- .../containers/use_get_cases_status.tsx | 4 +- .../containers/use_get_reporters.test.tsx | 37 +- .../public/containers/use_get_reporters.tsx | 6 +- .../public/containers/use_get_tags.test.tsx | 25 +- .../cases/public/containers/use_get_tags.tsx | 4 +- .../public/containers/use_post_case.test.tsx | 3 +- .../containers/use_post_comment.test.tsx | 3 +- .../plugins/cases/public/containers/utils.ts | 9 + .../methods/get_all_cases_selector_modal.tsx | 12 +- x-pack/plugins/cases/public/types.ts | 4 + .../__snapshots__/audit_logger.test.ts.snap | 1765 +++++++++++++++++ .../server/authorization/audit_logger.test.ts | 208 ++ .../server/authorization/audit_logger.ts | 101 + .../authorization/authorization.test.ts | 977 +++++++++ .../server/authorization/authorization.ts | 251 +++ .../cases/server/authorization/index.test.ts | 23 + .../cases/server/authorization/index.ts | 262 +++ .../cases/server/authorization/mock.ts | 20 + .../cases/server/authorization/types.ts | 119 ++ .../cases/server/authorization/utils.test.ts | 297 +++ .../cases/server/authorization/utils.ts | 65 + .../cases/server/client/alerts/client.ts | 44 + .../plugins/cases/server/client/alerts/get.ts | 17 +- .../client/alerts/update_status.test.ts | 27 - .../server/client/alerts/update_status.ts | 19 +- .../client/{comments => attachments}/add.ts | 229 ++- .../cases/server/client/attachments/client.ts | 74 + .../cases/server/client/attachments/delete.ts | 198 ++ .../cases/server/client/attachments/get.ts | 251 +++ .../cases/server/client/attachments/update.ts | 207 ++ .../cases/server/client/cases/client.ts | 111 ++ .../cases/server/client/cases/create.test.ts | 464 ----- .../cases/server/client/cases/create.ts | 80 +- .../cases/server/client/cases/delete.ts | 165 ++ .../plugins/cases/server/client/cases/find.ts | 110 + .../plugins/cases/server/client/cases/get.ts | 259 ++- .../plugins/cases/server/client/cases/mock.ts | 10 + .../plugins/cases/server/client/cases/push.ts | 296 ++- .../cases/server/client/cases/update.test.ts | 768 ------- .../cases/server/client/cases/update.ts | 211 +- .../cases/server/client/cases/utils.test.ts | 4 +- .../cases/server/client/cases/utils.ts | 6 +- x-pack/plugins/cases/server/client/client.ts | 296 +-- .../cases/server/client/client_internal.ts | 35 + .../cases/server/client/comments/add.test.ts | 593 ------ .../cases/server/client/configure/client.ts | 461 +++++ .../client/configure/create_mappings.ts | 54 + .../client/configure/get_fields.test.ts | 61 - .../server/client/configure/get_fields.ts | 19 +- .../client/configure/get_mappings.test.ts | 73 - .../server/client/configure/get_mappings.ts | 66 +- .../cases/server/client/configure/types.ts | 24 + .../client/configure/update_mappings.ts | 54 + x-pack/plugins/cases/server/client/factory.ts | 115 ++ .../plugins/cases/server/client/index.test.ts | 50 - x-pack/plugins/cases/server/client/index.ts | 18 +- x-pack/plugins/cases/server/client/mocks.ts | 175 +- .../cases/server/client/stats/client.ts | 90 + .../cases/server/client/sub_cases/client.ts | 265 +++ .../sub_cases/update.ts} | 166 +- .../cases/server/client/typedoc_interfaces.ts | 57 + x-pack/plugins/cases/server/client/types.ts | 123 +- .../server/client/user_actions/client.ts | 47 + .../cases/server/client/user_actions/get.ts | 92 +- .../plugins/cases/server/client/utils.test.ts | 329 +++ x-pack/plugins/cases/server/client/utils.ts | 479 +++++ x-pack/plugins/cases/server/common/error.ts | 2 +- x-pack/plugins/cases/server/common/index.ts | 1 + .../server/common/models/commentable_case.ts | 80 +- x-pack/plugins/cases/server/common/types.ts | 7 + .../plugins/cases/server/common/utils.test.ts | 650 +++++- x-pack/plugins/cases/server/common/utils.ts | 365 +++- .../server/connectors/case/index.test.ts | 61 +- .../cases/server/connectors/case/index.ts | 136 +- .../cases/server/connectors/case/schema.ts | 3 + .../plugins/cases/server/connectors/index.ts | 16 +- .../plugins/cases/server/connectors/types.ts | 33 +- x-pack/plugins/cases/server/index.ts | 3 + x-pack/plugins/cases/server/plugin.ts | 152 +- .../routes/api/__fixtures__/authc_mock.ts | 2 +- .../__fixtures__/create_mock_so_repository.ts | 305 --- .../server/routes/api/__fixtures__/index.ts | 4 - .../api/__fixtures__/mock_actions_client.ts | 34 - .../routes/api/__fixtures__/mock_router.ts | 42 - .../api/__fixtures__/mock_saved_objects.ts | 18 +- .../routes/api/__fixtures__/route_contexts.ts | 64 - .../routes/api/__mocks__/request_responses.ts | 141 +- .../routes/api/cases/alerts/get_cases.ts | 19 +- .../api/cases/comments/delete_all_comments.ts | 89 - .../api/cases/comments/delete_comment.test.ts | 66 - .../api/cases/comments/delete_comment.ts | 99 - .../api/cases/comments/find_comments.ts | 98 - .../api/cases/comments/get_all_comment.ts | 79 - .../api/cases/comments/get_comment.test.ts | 71 - .../api/cases/comments/patch_comment.test.ts | 378 ---- .../api/cases/comments/patch_comment.ts | 186 -- .../api/cases/comments/post_comment.test.ts | 326 --- .../api/cases/configure/get_configure.test.ts | 164 -- .../api/cases/configure/get_configure.ts | 71 - .../cases/configure/get_connectors.test.ts | 142 -- .../api/cases/configure/get_connectors.ts | 57 - .../cases/configure/patch_configure.test.ts | 259 --- .../api/cases/configure/patch_configure.ts | 120 -- .../cases/configure/post_configure.test.ts | 472 ----- .../api/cases/configure/post_configure.ts | 109 - .../routes/api/cases/delete_cases.test.ts | 114 -- .../server/routes/api/cases/delete_cases.ts | 101 +- .../routes/api/cases/find_cases.test.ts | 99 - .../server/routes/api/cases/find_cases.ts | 67 +- .../server/routes/api/cases/get_case.test.ts | 222 --- .../cases/server/routes/api/cases/get_case.ts | 12 +- .../server/routes/api/cases/helpers.test.ts | 111 -- .../cases/server/routes/api/cases/helpers.ts | 337 ---- .../routes/api/cases/patch_cases.test.ts | 412 ---- .../server/routes/api/cases/patch_cases.ts | 4 +- .../server/routes/api/cases/post_case.test.ts | 231 --- .../server/routes/api/cases/post_case.ts | 4 +- .../server/routes/api/cases/push_case.test.ts | 466 ----- .../server/routes/api/cases/push_case.ts | 10 +- .../api/cases/reporters/get_reporters.ts | 25 +- .../api/cases/status/get_status.test.ts | 85 - .../routes/api/cases/status/get_status.ts | 49 - .../api/cases/sub_case/delete_sub_cases.ts | 95 - .../api/cases/sub_case/find_sub_cases.ts | 99 - .../routes/api/cases/sub_case/get_sub_case.ts | 80 - .../server/routes/api/cases/tags/get_tags.ts | 25 +- .../api/comments/delete_all_comments.ts | 46 + .../routes/api/comments/delete_comment.ts | 48 + .../routes/api/comments/find_comments.ts | 53 + .../routes/api/comments/get_all_comment.ts | 49 + .../api/{cases => }/comments/get_comment.ts | 20 +- .../routes/api/comments/patch_comment.ts | 59 + .../api/{cases => }/comments/post_comment.ts | 11 +- .../routes/api/configure/get_configure.ts | 35 + .../routes/api/configure/get_connectors.ts | 33 + .../routes/api/configure/patch_configure.ts | 51 + .../routes/api/configure/post_configure.ts | 44 + .../plugins/cases/server/routes/api/index.ts | 34 +- .../server/routes/api/stats/get_status.ts | 32 + .../routes/api/sub_case/delete_sub_cases.ts | 37 + .../routes/api/sub_case/find_sub_cases.ts | 53 + .../routes/api/sub_case/get_sub_case.ts | 46 + .../routes/api/sub_case/patch_sub_cases.ts | 34 + .../plugins/cases/server/routes/api/types.ts | 17 - .../user_actions/get_all_user_actions.ts | 14 +- .../cases/server/routes/api/utils.test.ts | 834 +------- .../plugins/cases/server/routes/api/utils.ts | 392 +--- .../cases/server/saved_object_types/cases.ts | 8 +- .../server/saved_object_types/comments.ts | 8 +- .../server/saved_object_types/configure.ts | 8 +- .../saved_object_types/connector_mappings.ts | 10 +- .../cases/server/saved_object_types/index.ts | 15 +- .../server/saved_object_types/migrations.ts | 54 +- .../server/saved_object_types/sub_case.ts | 10 +- .../server/saved_object_types/user_actions.ts | 8 +- .../cases/server/scripts/sub_cases/index.ts | 10 +- .../cases/server/services/alerts/index.ts | 4 +- .../server/services/attachments/index.ts | 130 ++ .../cases/server/services/cases/index.ts | 1187 +++++++++++ .../cases/server/services/configure/index.ts | 155 +- .../services/connector_mappings/index.ts | 99 +- x-pack/plugins/cases/server/services/index.ts | 1197 +---------- x-pack/plugins/cases/server/services/mocks.ts | 148 +- .../services/reporters/read_reporters.ts | 47 - .../cases/server/services/tags/read_tags.ts | 60 - .../server/services/user_actions/helpers.ts | 33 +- .../server/services/user_actions/index.ts | 93 +- x-pack/plugins/cases/server/types.ts | 20 +- .../common/feature_kibana_privileges.ts | 28 + .../plugins/features/common/kibana_feature.ts | 9 + .../__snapshots__/oss_features.test.ts.snap | 24 + .../feature_privilege_iterator.test.ts | 161 ++ .../feature_privilege_iterator.ts | 5 + .../features/server/feature_registry.test.ts | 174 ++ .../plugins/features/server/feature_schema.ts | 47 +- .../actions/__snapshots__/cases.test.ts.snap | 25 + .../authorization/actions/actions.mock.ts | 3 + .../server/authorization/actions/actions.ts | 3 + .../authorization/actions/cases.test.ts | 45 + .../server/authorization/actions/cases.ts | 28 + .../feature_privilege_builder/cases.test.ts | 263 +++ .../feature_privilege_builder/cases.ts | 52 + .../feature_privilege_builder/index.ts | 2 + x-pack/plugins/security/server/plugin.test.ts | 6 + .../integration/cases/connectors.spec.ts | 24 +- .../integration/cases/privileges.spec.ts | 234 +++ .../security_solution/cypress/objects/case.ts | 9 +- .../cypress/tasks/api_calls/cases.ts | 1 + .../security_solution/cypress/tasks/common.ts | 31 +- .../cypress/tasks/create_new_case.ts | 3 +- .../security_solution/cypress/tasks/login.ts | 71 + .../cases/components/all_cases/index.tsx | 1 + .../cases/components/callout/helpers.tsx | 4 +- .../cases/components/callout/translations.ts | 8 +- .../public/cases/components/create/flyout.tsx | 2 + .../cases/components/create/index.test.tsx | 2 + .../public/cases/components/create/index.tsx | 2 + .../add_to_case_action.test.tsx | 15 +- .../timeline_actions/add_to_case_action.tsx | 8 +- .../public/cases/pages/case.tsx | 8 +- .../public/cases/pages/case_details.tsx | 4 +- .../public/cases/pages/configure_cases.tsx | 6 +- .../public/cases/pages/create_case.tsx | 4 +- ...issions.tsx => feature_no_permissions.tsx} | 14 +- .../public/cases/pages/translations.ts | 10 +- .../public/cases/translations.ts | 10 +- .../common/lib/kibana/__mocks__/index.ts | 2 +- .../public/common/lib/kibana/hooks.ts | 17 +- .../containers/detection_engine/alerts/api.ts | 3 + .../alerts/use_cases_from_alerts.tsx | 3 +- .../components/recent_cases/index.tsx | 1 + .../flyout/add_to_case_button/index.tsx | 5 +- .../endpoint/endpoint_app_context_services.ts | 14 + .../server/endpoint/mocks.ts | 3 + .../endpoint/routes/actions/isolation.ts | 41 + .../security_solution/server/plugin.ts | 72 +- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - x-pack/scripts/functional_tests.js | 5 +- .../apis/security/privileges.ts | 2 +- .../security_solution/cases_privileges.ts | 331 ++++ .../apis/security_solution/index.js | 1 + .../test/api_integration_basic/apis/index.ts | 1 + .../security_solution/cases_privileges.ts | 183 ++ .../apis/security_solution/index.ts | 14 + .../basic/tests/cases/alerts/get_cases.ts | 112 -- .../tests/cases/comments/delete_comment.ts | 171 -- .../tests/cases/comments/find_comments.ts | 155 -- .../tests/cases/comments/get_all_comments.ts | 133 -- .../basic/tests/cases/comments/get_comment.ts | 79 - .../tests/cases/comments/patch_comment.ts | 414 ---- .../tests/cases/comments/post_comment.ts | 422 ---- .../basic/tests/cases/delete_cases.ts | 151 -- .../basic/tests/cases/find_cases.ts | 667 ------- .../basic/tests/cases/get_case.ts | 60 - .../basic/tests/cases/patch_cases.ts | 935 --------- .../basic/tests/cases/post_case.ts | 85 - .../basic/tests/cases/push_case.ts | 381 ---- .../tests/cases/reporters/get_reporters.ts | 37 - .../basic/tests/cases/status/get_status.ts | 80 - .../basic/tests/cases/tags/get_tags.ts | 42 - .../basic/tests/configure/get_configure.ts | 56 - .../basic/tests/configure/patch_configure.ts | 112 -- .../basic/tests/configure/post_configure.ts | 81 - .../case_api_integration/basic/tests/index.ts | 48 - .../case_api_integration/common/config.ts | 25 +- .../plugins/cases_client_user/kibana.json | 10 + .../plugins/cases_client_user/package.json | 14 + .../plugins/cases_client_user/server/index.ts | 12 + .../cases_client_user/server/plugin.ts | 68 + .../plugins/observability/kibana.json | 10 + .../plugins/observability/package.json | 14 + .../plugins/observability/server/index.ts | 10 + .../plugins/observability/server/plugin.ts | 60 + .../plugins/security_solution/kibana.json | 10 + .../plugins/security_solution/package.json | 14 + .../plugins/security_solution/server/index.ts | 10 + .../security_solution/server/plugin.ts | 93 + .../common/lib/authentication/index.ts | 105 + .../common/lib/authentication/roles.ts | 292 +++ .../common/lib/authentication/spaces.ts | 22 + .../common/lib/authentication/types.ts | 54 + .../common/lib/authentication/users.ts | 149 ++ .../case_api_integration/common/lib/mock.ts | 75 +- .../case_api_integration/common/lib/utils.ts | 747 ++++++- .../security_and_spaces/config_basic.ts | 15 + .../config_trial.ts} | 4 +- .../tests/basic/cases/push_case.ts | 76 + .../tests/basic/configure/create_connector.ts | 20 + .../security_and_spaces/tests/basic/index.ts | 34 + .../tests/common/alerts/get_cases.ts | 312 +++ .../tests/common/cases/delete_cases.ts | 318 +++ .../tests/common/cases/find_cases.ts | 815 ++++++++ .../tests/common/cases/get_case.ts | 207 ++ .../tests/common}/cases/migrations.ts | 4 +- .../tests/common/cases/patch_cases.ts | 1240 ++++++++++++ .../tests/common/cases/post_case.ts | 314 +++ .../common/cases/reporters/get_reporters.ts | 201 ++ .../tests/common/cases/status/get_status.ts | 185 ++ .../tests/common/cases/tags/get_tags.ts | 201 ++ .../common/client/update_alert_status.ts | 167 ++ .../tests/common/comments/delete_comment.ts | 371 ++++ .../tests/common/comments/find_comments.ts | 393 ++++ .../tests/common/comments/get_all_comments.ts | 231 +++ .../tests/common/comments/get_comment.ts | 169 ++ .../tests/common}/comments/migrations.ts | 2 +- .../tests/common/comments/patch_comment.ts | 641 ++++++ .../tests/common/comments/post_comment.ts | 605 ++++++ .../tests/common/configure/get_configure.ts | 215 ++ .../tests/common/configure/get_connectors.ts | 27 + .../tests/common}/configure/migrations.ts | 11 +- .../tests/common/configure/patch_configure.ts | 269 +++ .../tests/common/configure/post_configure.ts | 315 +++ .../tests/common}/connectors/case.ts | 104 +- .../security_and_spaces/tests/common/index.ts | 43 + .../tests/common/migrations.ts | 18 + .../common}/sub_cases/delete_sub_cases.ts | 2 +- .../tests/common}/sub_cases/find_sub_cases.ts | 1 + .../tests/common}/sub_cases/get_sub_case.ts | 12 +- .../common}/sub_cases/patch_sub_cases.ts | 7 +- .../user_actions/get_all_user_actions.ts | 178 +- .../tests/common}/user_actions/migrations.ts | 2 +- .../tests/trial/cases/push_case.ts | 315 +++ .../user_actions/get_all_user_actions.ts | 115 ++ .../tests/trial/configure/get_configure.ts | 98 + .../tests/trial}/configure/get_connectors.ts | 43 +- .../tests/trial/configure/index.ts | 18 + .../tests/trial/configure/patch_configure.ts | 168 ++ .../tests/trial/configure/post_configure.ts | 98 + .../security_and_spaces/tests/trial/index.ts | 36 + .../security_only/config.ts | 16 + .../tests/common/alerts/get_cases.ts | 242 +++ .../tests/common/cases/delete_cases.ts | 157 ++ .../tests/common/cases/find_cases.ts | 245 +++ .../tests/common/cases/get_case.ts | 144 ++ .../tests/common/cases/patch_cases.ts | 243 +++ .../tests/common/cases/post_case.ts | 83 + .../common/cases/reporters/get_reporters.ts | 155 ++ .../tests/common/cases/status/get_status.ts | 144 ++ .../tests/common/cases/tags/get_tags.ts | 170 ++ .../tests/common/comments/delete_comment.ts | 205 ++ .../tests/common/comments/find_comments.ts | 278 +++ .../tests/common/comments/get_all_comments.ts | 139 ++ .../tests/common/comments/get_comment.ts | 123 ++ .../tests/common/comments/patch_comment.ts | 189 ++ .../tests/common/comments/post_comment.ts | 128 ++ .../tests/common/configure/get_configure.ts | 195 ++ .../tests/common/configure/patch_configure.ts | 140 ++ .../tests/common/configure/post_configure.ts | 133 ++ .../security_only/tests/common/index.ts | 33 + .../user_actions/get_all_user_actions.ts | 104 + .../tests/trial/cases/push_case.ts | 128 ++ .../security_only/tests/trial/index.ts | 34 + .../security_only/utils.ts | 18 + .../spaces_only/config.ts | 16 + .../tests/common/alerts/get_cases.ts | 110 + .../tests/common/cases/delete_cases.ts | 53 + .../tests/common/cases/find_cases.ts | 63 + .../tests/common/cases/get_case.ts | 49 + .../tests/common/cases/patch_cases.ts | 74 + .../tests/common/cases/post_case.ts | 64 + .../common/cases/reporters/get_reporters.ts | 47 + .../tests/common/cases/status/get_status.ts | 92 + .../tests/common/cases/tags/get_tags.ts | 42 + .../tests/common/comments/delete_comment.ts | 72 + .../tests/common/comments/find_comments.ts | 82 + .../tests/common/comments/get_all_comments.ts | 73 + .../tests/common/comments/get_comment.ts | 66 + .../tests/common/comments/patch_comment.ts | 90 + .../tests/common/comments/post_comment.ts | 75 + .../tests/common/configure/get_configure.ts | 51 + .../tests/common/configure/patch_configure.ts | 77 + .../tests/common/configure/post_configure.ts | 44 + .../spaces_only/tests/common/index.ts | 33 + .../user_actions/get_all_user_actions.ts | 48 + .../tests/trial/cases/push_case.ts | 96 + .../tests/trial/configure/get_configure.ts | 135 ++ .../tests/trial/configure/get_connectors.ts | 181 ++ .../tests/trial/configure/index.ts | 18 + .../tests/trial/configure/patch_configure.ts | 164 ++ .../tests/trial/configure/post_configure.ts | 107 + .../spaces_only/tests/trial/index.ts | 30 + 487 files changed, 33153 insertions(+), 17226 deletions(-) create mode 100644 x-pack/plugins/cases/common/api/cases/constants.ts create mode 100644 x-pack/plugins/cases/docs/README.md create mode 100644 x-pack/plugins/cases/docs/cases_client/cases_client_api.md create mode 100644 x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.iallcommentsresponse.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasepostrequest.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icaseresponse.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigurepatch.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigurerequest.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigureresponse.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesfindrequest.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesfindresponse.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasespatchrequest.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesresponse.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icaseuseractionsresponse.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icommentsresponse.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcaseresponse.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcasesfindresponse.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcasesresponse.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md create mode 100644 x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/attachments_add.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/attachments_client.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/attachments_delete.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/attachments_get.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/attachments_update.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/cases_client.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/cases_get.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/cases_push.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/client.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/configure_client.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/stats_client.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/sub_cases_client.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/typedoc_interfaces.md create mode 100644 x-pack/plugins/cases/docs/cases_client/modules/user_actions_client.md create mode 100644 x-pack/plugins/cases/docs/cases_client_typedoc.json delete mode 100644 x-pack/plugins/cases/public/components/create/flyout.test.tsx delete mode 100644 x-pack/plugins/cases/public/components/create/flyout.tsx create mode 100644 x-pack/plugins/cases/public/components/owner_context/index.tsx create mode 100644 x-pack/plugins/cases/public/components/owner_context/use_owner_context.ts create mode 100644 x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap create mode 100644 x-pack/plugins/cases/server/authorization/audit_logger.test.ts create mode 100644 x-pack/plugins/cases/server/authorization/audit_logger.ts create mode 100644 x-pack/plugins/cases/server/authorization/authorization.test.ts create mode 100644 x-pack/plugins/cases/server/authorization/authorization.ts create mode 100644 x-pack/plugins/cases/server/authorization/index.test.ts create mode 100644 x-pack/plugins/cases/server/authorization/index.ts create mode 100644 x-pack/plugins/cases/server/authorization/mock.ts create mode 100644 x-pack/plugins/cases/server/authorization/types.ts create mode 100644 x-pack/plugins/cases/server/authorization/utils.test.ts create mode 100644 x-pack/plugins/cases/server/authorization/utils.ts create mode 100644 x-pack/plugins/cases/server/client/alerts/client.ts delete mode 100644 x-pack/plugins/cases/server/client/alerts/update_status.test.ts rename x-pack/plugins/cases/server/client/{comments => attachments}/add.ts (63%) create mode 100644 x-pack/plugins/cases/server/client/attachments/client.ts create mode 100644 x-pack/plugins/cases/server/client/attachments/delete.ts create mode 100644 x-pack/plugins/cases/server/client/attachments/get.ts create mode 100644 x-pack/plugins/cases/server/client/attachments/update.ts create mode 100644 x-pack/plugins/cases/server/client/cases/client.ts delete mode 100644 x-pack/plugins/cases/server/client/cases/create.test.ts create mode 100644 x-pack/plugins/cases/server/client/cases/delete.ts create mode 100644 x-pack/plugins/cases/server/client/cases/find.ts delete mode 100644 x-pack/plugins/cases/server/client/cases/update.test.ts create mode 100644 x-pack/plugins/cases/server/client/client_internal.ts delete mode 100644 x-pack/plugins/cases/server/client/comments/add.test.ts create mode 100644 x-pack/plugins/cases/server/client/configure/client.ts create mode 100644 x-pack/plugins/cases/server/client/configure/create_mappings.ts delete mode 100644 x-pack/plugins/cases/server/client/configure/get_fields.test.ts delete mode 100644 x-pack/plugins/cases/server/client/configure/get_mappings.test.ts create mode 100644 x-pack/plugins/cases/server/client/configure/types.ts create mode 100644 x-pack/plugins/cases/server/client/configure/update_mappings.ts create mode 100644 x-pack/plugins/cases/server/client/factory.ts delete mode 100644 x-pack/plugins/cases/server/client/index.test.ts create mode 100644 x-pack/plugins/cases/server/client/stats/client.ts create mode 100644 x-pack/plugins/cases/server/client/sub_cases/client.ts rename x-pack/plugins/cases/server/{routes/api/cases/sub_case/patch_sub_cases.ts => client/sub_cases/update.ts} (77%) create mode 100644 x-pack/plugins/cases/server/client/typedoc_interfaces.ts create mode 100644 x-pack/plugins/cases/server/client/user_actions/client.ts create mode 100644 x-pack/plugins/cases/server/client/utils.test.ts create mode 100644 x-pack/plugins/cases/server/client/utils.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/__fixtures__/create_mock_so_repository.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/__fixtures__/mock_actions_client.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/__fixtures__/mock_router.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/__fixtures__/route_contexts.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/helpers.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts delete mode 100644 x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts create mode 100644 x-pack/plugins/cases/server/routes/api/comments/delete_all_comments.ts create mode 100644 x-pack/plugins/cases/server/routes/api/comments/delete_comment.ts create mode 100644 x-pack/plugins/cases/server/routes/api/comments/find_comments.ts create mode 100644 x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts rename x-pack/plugins/cases/server/routes/api/{cases => }/comments/get_comment.ts (59%) create mode 100644 x-pack/plugins/cases/server/routes/api/comments/patch_comment.ts rename x-pack/plugins/cases/server/routes/api/{cases => }/comments/post_comment.ts (80%) create mode 100644 x-pack/plugins/cases/server/routes/api/configure/get_configure.ts create mode 100644 x-pack/plugins/cases/server/routes/api/configure/get_connectors.ts create mode 100644 x-pack/plugins/cases/server/routes/api/configure/patch_configure.ts create mode 100644 x-pack/plugins/cases/server/routes/api/configure/post_configure.ts create mode 100644 x-pack/plugins/cases/server/routes/api/stats/get_status.ts create mode 100644 x-pack/plugins/cases/server/routes/api/sub_case/delete_sub_cases.ts create mode 100644 x-pack/plugins/cases/server/routes/api/sub_case/find_sub_cases.ts create mode 100644 x-pack/plugins/cases/server/routes/api/sub_case/get_sub_case.ts create mode 100644 x-pack/plugins/cases/server/routes/api/sub_case/patch_sub_cases.ts rename x-pack/plugins/cases/server/routes/api/{cases => }/user_actions/get_all_user_actions.ts (84%) create mode 100644 x-pack/plugins/cases/server/services/attachments/index.ts create mode 100644 x-pack/plugins/cases/server/services/cases/index.ts delete mode 100644 x-pack/plugins/cases/server/services/reporters/read_reporters.ts delete mode 100644 x-pack/plugins/cases/server/services/tags/read_tags.ts create mode 100644 x-pack/plugins/security/server/authorization/actions/__snapshots__/cases.test.ts.snap create mode 100644 x-pack/plugins/security/server/authorization/actions/cases.test.ts create mode 100644 x-pack/plugins/security/server/authorization/actions/cases.ts create mode 100644 x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.test.ts create mode 100644 x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts create mode 100644 x-pack/plugins/security_solution/cypress/integration/cases/privileges.spec.ts rename x-pack/plugins/security_solution/public/cases/pages/{saved_object_no_permissions.tsx => feature_no_permissions.tsx} (64%) create mode 100644 x-pack/test/api_integration/apis/security_solution/cases_privileges.ts create mode 100644 x-pack/test/api_integration_basic/apis/security_solution/cases_privileges.ts create mode 100644 x-pack/test/api_integration_basic/apis/security_solution/index.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/alerts/get_cases.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/get_case.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/post_case.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/push_case.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/reporters/get_reporters.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/configure/get_configure.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/configure/patch_configure.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/configure/post_configure.ts delete mode 100644 x-pack/test/case_api_integration/basic/tests/index.ts create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/kibana.json create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/package.json create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/server/index.ts create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/server/plugin.ts create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/observability/kibana.json create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/observability/package.json create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/observability/server/index.ts create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/observability/server/plugin.ts create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/kibana.json create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/package.json create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/server/index.ts create mode 100644 x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/server/plugin.ts create mode 100644 x-pack/test/case_api_integration/common/lib/authentication/index.ts create mode 100644 x-pack/test/case_api_integration/common/lib/authentication/roles.ts create mode 100644 x-pack/test/case_api_integration/common/lib/authentication/spaces.ts create mode 100644 x-pack/test/case_api_integration/common/lib/authentication/types.ts create mode 100644 x-pack/test/case_api_integration/common/lib/authentication/users.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/config_basic.ts rename x-pack/test/case_api_integration/{basic/config.ts => security_and_spaces/config_trial.ts} (78%) create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/basic/cases/push_case.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/basic/configure/create_connector.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/basic/index.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/find_cases.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/get_case.ts rename x-pack/test/case_api_integration/{basic/tests => security_and_spaces/tests/common}/cases/migrations.ts (95%) create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/status/get_status.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/tags/get_tags.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/client/update_alert_status.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/delete_comment.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/find_comments.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_comment.ts rename x-pack/test/case_api_integration/{basic/tests/cases => security_and_spaces/tests/common}/comments/migrations.ts (93%) create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/post_comment.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/get_configure.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/get_connectors.ts rename x-pack/test/case_api_integration/{basic/tests => security_and_spaces/tests/common}/configure/migrations.ts (76%) create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/post_configure.ts rename x-pack/test/case_api_integration/{basic/tests => security_and_spaces/tests/common}/connectors/case.ts (92%) create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/index.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts rename x-pack/test/case_api_integration/{basic/tests/cases => security_and_spaces/tests/common}/sub_cases/delete_sub_cases.ts (98%) rename x-pack/test/case_api_integration/{basic/tests/cases => security_and_spaces/tests/common}/sub_cases/find_sub_cases.ts (99%) rename x-pack/test/case_api_integration/{basic/tests/cases => security_and_spaces/tests/common}/sub_cases/get_sub_case.ts (95%) rename x-pack/test/case_api_integration/{basic/tests/cases => security_and_spaces/tests/common}/sub_cases/patch_sub_cases.ts (98%) rename x-pack/test/case_api_integration/{basic/tests/cases => security_and_spaces/tests/common}/user_actions/get_all_user_actions.ts (73%) rename x-pack/test/case_api_integration/{basic/tests/cases => security_and_spaces/tests/common}/user_actions/migrations.ts (95%) create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts rename x-pack/test/case_api_integration/{basic/tests => security_and_spaces/tests/trial}/configure/get_connectors.ts (75%) create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/index.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts create mode 100644 x-pack/test/case_api_integration/security_and_spaces/tests/trial/index.ts create mode 100644 x-pack/test/case_api_integration/security_only/config.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/cases/delete_cases.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/cases/find_cases.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/cases/get_case.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/cases/patch_cases.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/cases/post_case.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/cases/reporters/get_reporters.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/cases/status/get_status.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/cases/tags/get_tags.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/comments/delete_comment.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/comments/find_comments.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/comments/get_all_comments.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/comments/get_comment.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/comments/patch_comment.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/comments/post_comment.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/configure/get_configure.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/configure/patch_configure.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/configure/post_configure.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/index.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/common/user_actions/get_all_user_actions.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts create mode 100644 x-pack/test/case_api_integration/security_only/tests/trial/index.ts create mode 100644 x-pack/test/case_api_integration/security_only/utils.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/config.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/cases/delete_cases.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/cases/find_cases.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/cases/get_case.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/cases/patch_cases.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/cases/post_case.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/cases/reporters/get_reporters.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/cases/status/get_status.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/cases/tags/get_tags.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/comments/delete_comment.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/comments/find_comments.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/comments/get_all_comments.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/comments/get_comment.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/comments/patch_comment.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/comments/post_comment.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/configure/get_configure.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/configure/patch_configure.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/configure/post_configure.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/index.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/common/user_actions/get_all_user_actions.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/trial/configure/index.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts create mode 100644 x-pack/test/case_api_integration/spaces_only/tests/trial/index.ts diff --git a/x-pack/plugins/cases/README.md b/x-pack/plugins/cases/README.md index 5cb9d82436137..a1660911567da 100644 --- a/x-pack/plugins/cases/README.md +++ b/x-pack/plugins/cases/README.md @@ -14,6 +14,7 @@ Case management in Kibana ## Table of Contents - [Cases API](#cases-api) +- [Cases Client API](#cases-client-api) - [Cases UI](#cases-ui) - [Case Action Type](#case-action-type) _feature in development, disabled by default_ @@ -21,6 +22,9 @@ Case management in Kibana ## Cases API [**Explore the API docs ยป**](https://www.elastic.co/guide/en/security/current/cases-api-overview.html) +## Cases Client API +[**Cases Client API docs**][cases-client-api-docs] + ## Cases UI #### Embed Cases UI components in any Kibana plugin @@ -263,4 +267,4 @@ For IBM Resilient connectors: [all-cases-modal-img]: images/all_cases_selector_modal.png [recent-cases-img]: images/recent_cases.png [case-view-img]: images/case_view.png - +[cases-client-api-docs]: docs/cases_client/cases_client_api.md diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index a2bba7dba4b39..b3f7952a61ee7 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -31,13 +31,38 @@ const SettingsRt = rt.type({ }); const CaseBasicRt = rt.type({ + /** + * The description of the case + */ description: rt.string, + /** + * The current status of the case (open, closed, in-progress) + */ status: CaseStatusRt, + /** + * The identifying strings for filter a case + */ tags: rt.array(rt.string), + /** + * The title of a case + */ title: rt.string, + /** + * The type of a case (individual or collection) + */ [caseTypeField]: CaseTypeRt, + /** + * The external system that the case can be synced with + */ connector: CaseConnectorRt, + /** + * The alert sync settings + */ settings: SettingsRt, + /** + * The plugin owner of the case + */ + owner: rt.string, }); const CaseExternalServiceBasicRt = rt.type({ @@ -73,11 +98,31 @@ export const CaseAttributesRt = rt.intersection([ ]); const CasePostRequestNoTypeRt = rt.type({ + /** + * Description of the case + */ description: rt.string, + /** + * Identifiers for the case. + */ tags: rt.array(rt.string), + /** + * Title of the case + */ title: rt.string, + /** + * The external configuration for the case + */ connector: CaseConnectorRt, + /** + * Sync settings for alerts + */ settings: SettingsRt, + /** + * The owner here must match the string used when a plugin registers a feature with access to the cases plugin. The user + * creating this case must also be granted access to that plugin's feature. + */ + owner: rt.string, }); /** @@ -95,23 +140,78 @@ export const CasesClientPostRequestRt = rt.type({ * has all the necessary fields. CasesClientPostRequestRt is used for validation. */ export const CasePostRequestRt = rt.intersection([ - rt.partial({ type: CaseTypeRt }), + /** + * The case type: an individual case (one without children) or a collection case (one with children) + */ + rt.partial({ [caseTypeField]: CaseTypeRt }), CasePostRequestNoTypeRt, ]); export const CasesFindRequestRt = rt.partial({ + /** + * Type of a case (individual, or collection) + */ type: CaseTypeRt, + /** + * Tags to filter by + */ tags: rt.union([rt.array(rt.string), rt.string]), + /** + * The status of the case (open, closed, in-progress) + */ status: CaseStatusRt, + /** + * The reporters to filter by + */ reporters: rt.union([rt.array(rt.string), rt.string]), + /** + * Operator to use for the `search` field + */ defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + /** + * The fields in the entity to return in the response + */ fields: rt.array(rt.string), + /** + * The page of objects to return + */ page: NumberFromString, + /** + * The number of objects to include in each page + */ perPage: NumberFromString, + /** + * An Elasticsearch simple_query_string + */ search: rt.string, - searchFields: rt.array(rt.string), + /** + * The fields to perform the simple_query_string parsed query against + */ + searchFields: rt.union([rt.array(rt.string), rt.string]), + /** + * The field to use for sorting the found objects. + * + * This only supports, `create_at`, `closed_at`, and `status` + */ sortField: rt.string, + /** + * The order to sort by + */ sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), + /** + * The owner(s) to filter by. The user making the request must have privileges to retrieve cases of that + * ownership or they will be ignored. If no owner is included, then all ownership types will be included in the response + * that the user has access to. + */ + owner: rt.union([rt.array(rt.string), rt.string]), +}); + +export const CasesByAlertIDRequestRt = rt.partial({ + /** + * The type of cases to retrieve given an alert ID. If no owner is provided, all cases + * that the user has access to will be returned. + */ + owner: rt.union([rt.array(rt.string), rt.string]), }); export const CaseResponseRt = rt.intersection([ @@ -141,6 +241,9 @@ export const CasesFindResponseRt = rt.intersection([ export const CasePatchRequestRt = rt.intersection([ rt.partial(CaseBasicRt.props), + /** + * The saved object ID and version + */ rt.type({ id: rt.string, version: rt.string }), ]); @@ -172,6 +275,16 @@ export const ExternalServiceResponseRt = rt.intersection([ }), ]); +export const AllTagsFindRequestRt = rt.partial({ + /** + * The owner of the cases to retrieve the tags from. If no owner is provided the tags from all cases + * that the user has access to will be returned. + */ + owner: rt.union([rt.array(rt.string), rt.string]), +}); + +export const AllReportersFindRequestRt = AllTagsFindRequestRt; + export type CaseAttributes = rt.TypeOf; /** * This field differs from the CasePostRequest in that the post request's type field can be optional. This type requires @@ -183,6 +296,7 @@ export type CasePostRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; export type CasesResponse = rt.TypeOf; export type CasesFindRequest = rt.TypeOf; +export type CasesByAlertIDRequest = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; @@ -194,3 +308,6 @@ export type ESCaseAttributes = Omit & { connector: export type ESCasePatchRequest = Omit & { connector?: ESCaseConnector; }; + +export type AllTagsFindRequest = rt.TypeOf; +export type AllReportersFindRequest = AllTagsFindRequest; diff --git a/x-pack/plugins/cases/common/api/cases/comment.ts b/x-pack/plugins/cases/common/api/cases/comment.ts index 8434e419ef75a..5bc8da95639c8 100644 --- a/x-pack/plugins/cases/common/api/cases/comment.ts +++ b/x-pack/plugins/cases/common/api/cases/comment.ts @@ -6,6 +6,7 @@ */ import * as rt from 'io-ts'; +import { SavedObjectFindOptionsRt } from '../saved_object'; import { UserRT } from '../user'; @@ -42,6 +43,7 @@ export const CommentAttributesBasicRt = rt.type({ ]), created_at: rt.string, created_by: UserRT, + owner: rt.string, pushed_at: rt.union([rt.string, rt.null]), pushed_by: rt.union([UserRT, rt.null]), updated_at: rt.union([rt.string, rt.null]), @@ -57,6 +59,7 @@ export enum CommentType { export const ContextTypeUserRt = rt.type({ comment: rt.string, type: rt.literal(CommentType.user), + owner: rt.string, }); /** @@ -72,6 +75,7 @@ export const AlertCommentRequestRt = rt.type({ id: rt.union([rt.string, rt.null]), name: rt.union([rt.string, rt.null]), }), + owner: rt.string, }); const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); @@ -127,7 +131,17 @@ export const CommentsResponseRt = rt.type({ export const AllCommentsResponseRt = rt.array(CommentResponseRt); +export const FindQueryParamsRt = rt.partial({ + ...SavedObjectFindOptionsRt.props, + /** + * If specified the attachments found will be associated to a sub case instead of a case object + */ + subCaseId: rt.string, +}); + +export type FindQueryParams = rt.TypeOf; export type AttributesTypeAlerts = rt.TypeOf; +export type AttributesTypeUser = rt.TypeOf; export type CommentAttributes = rt.TypeOf; export type CommentRequest = rt.TypeOf; export type CommentResponse = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/cases/configure.ts b/x-pack/plugins/cases/common/api/cases/configure.ts index b5a89efde1767..2814dd44f513f 100644 --- a/x-pack/plugins/cases/common/api/cases/configure.ts +++ b/x-pack/plugins/cases/common/api/cases/configure.ts @@ -9,18 +9,34 @@ import * as rt from 'io-ts'; import { UserRT } from '../user'; import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors'; +import { OmitProp } from '../runtime_types'; +import { OWNER_FIELD } from './constants'; // TODO: we will need to add this type rt.literal('close-by-third-party') const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); const CasesConfigureBasicRt = rt.type({ + /** + * The external connector + */ connector: CaseConnectorRt, + /** + * Whether to close the case after it has been synced with the external system + */ closure_type: ClosureTypeRT, + /** + * The plugin owner that manages this configuration + */ + owner: rt.string, }); +const CasesConfigureBasicWithoutOwnerRt = rt.type( + OmitProp(CasesConfigureBasicRt.props, OWNER_FIELD) +); + export const CasesConfigureRequestRt = CasesConfigureBasicRt; export const CasesConfigurePatchRt = rt.intersection([ - rt.partial(CasesConfigureBasicRt.props), + rt.partial(CasesConfigureBasicWithoutOwnerRt.props), rt.type({ version: rt.string }), ]); @@ -38,18 +54,37 @@ export const CaseConfigureResponseRt = rt.intersection([ CaseConfigureAttributesRt, ConnectorMappingsRt, rt.type({ + id: rt.string, version: rt.string, error: rt.union([rt.string, rt.null]), + owner: rt.string, }), ]); +export const GetConfigureFindRequestRt = rt.partial({ + /** + * The configuration plugin owner to filter the search by. If this is left empty the results will include all configurations + * that the user has permissions to access + */ + owner: rt.union([rt.array(rt.string), rt.string]), +}); + +export const CaseConfigureRequestParamsRt = rt.type({ + configuration_id: rt.string, +}); + +export const CaseConfigurationsResponseRt = rt.array(CaseConfigureResponseRt); + export type ClosureType = rt.TypeOf; export type CasesConfigure = rt.TypeOf; export type CasesConfigureRequest = rt.TypeOf; export type CasesConfigurePatch = rt.TypeOf; export type CasesConfigureAttributes = rt.TypeOf; export type CasesConfigureResponse = rt.TypeOf; +export type CasesConfigurationsResponse = rt.TypeOf; export type ESCasesConfigureAttributes = Omit & { connector: ESCaseConnector; }; + +export type GetConfigureFindRequest = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/cases/constants.ts b/x-pack/plugins/cases/common/api/cases/constants.ts new file mode 100644 index 0000000000000..92755ec633ecc --- /dev/null +++ b/x-pack/plugins/cases/common/api/cases/constants.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * This field is used for authorization of the entities within the cases plugin. Each entity within Cases will have the owner field + * set to a string that represents the plugin that "owns" (i.e. the plugin that originally issued the POST request to + * create the entity) the entity. + * + * The Authorization class constructs a string composed of the operation being performed (createCase, getComment, etc), + * and the owner of the entity being acted upon or created. This string is then given to the Security plugin which + * checks to see if the user making the request has that particular string stored within it's privileges. If it does, + * then the operation succeeds, otherwise the operation fails. + * + * APIs that create/update an entity require that the owner field be passed in the body of the request. + * APIs that search for entities typically require that the owner be passed as a query parameter. + * APIs that specify an ID of an entity directly generally don't need to specify the owner field. + * + * For APIs that create/update an entity, the RBAC implementation checks to see if the user making the request has the + * correct privileges for performing that action (a create/update) for the specified owner. + * This check is done through the Security plugin's API. + * + * For APIs that search for entities, the RBAC implementation creates a filter for the saved objects query that limits + * the search to only owners that the user has access to. We also check that the objects returned by the saved objects + * API have the limited owner scope. If we find one that the user does not have permissions for, we throw a 403 error. + * The owner field that is passed in as a query parameter can be used to further limit the results. If a user attempts + * to pass an owner that they do not have access to, the owner is ignored. + * + * For APIs that retrieve/delete entities directly using their ID, the RBAC implementation requests the object first, + * and then checks to see if the user making the request has access to that operation and owner. If the user does, the + * operation continues, otherwise we throw a 403. + */ +export const OWNER_FIELD = 'owner'; diff --git a/x-pack/plugins/cases/common/api/cases/index.ts b/x-pack/plugins/cases/common/api/cases/index.ts index 6e7fb818cb2b5..0f78ca9b35377 100644 --- a/x-pack/plugins/cases/common/api/cases/index.ts +++ b/x-pack/plugins/cases/common/api/cases/index.ts @@ -11,3 +11,4 @@ export * from './comment'; export * from './status'; export * from './user_actions'; export * from './sub_case'; +export * from './constants'; diff --git a/x-pack/plugins/cases/common/api/cases/status.ts b/x-pack/plugins/cases/common/api/cases/status.ts index 7286e19da9159..d37e68007a21d 100644 --- a/x-pack/plugins/cases/common/api/cases/status.ts +++ b/x-pack/plugins/cases/common/api/cases/status.ts @@ -27,4 +27,13 @@ export const CasesStatusResponseRt = rt.type({ count_closed_cases: rt.number, }); +export const CasesStatusRequestRt = rt.partial({ + /** + * The owner of the cases to retrieve the status stats from. If no owner is provided the stats for all cases + * that the user has access to will be returned. + */ + owner: rt.union([rt.array(rt.string), rt.string]), +}); + export type CasesStatusResponse = rt.TypeOf; +export type CasesStatusRequest = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/cases/sub_case.ts b/x-pack/plugins/cases/common/api/cases/sub_case.ts index c46f87c547d50..654b74276733b 100644 --- a/x-pack/plugins/cases/common/api/cases/sub_case.ts +++ b/x-pack/plugins/cases/common/api/cases/sub_case.ts @@ -14,6 +14,9 @@ import { CasesStatusResponseRt } from './status'; import { CaseStatusRt } from './status'; const SubCaseBasicRt = rt.type({ + /** + * The status of the sub case (open, closed, in-progress) + */ status: CaseStatusRt, }); @@ -26,19 +29,48 @@ export const SubCaseAttributesRt = rt.intersection([ created_by: rt.union([UserRT, rt.null]), updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), + owner: rt.string, }), ]); export const SubCasesFindRequestRt = rt.partial({ + /** + * The status of the sub case (open, closed, in-progress) + */ status: CaseStatusRt, + /** + * Operator to use for the `search` field + */ defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + /** + * The fields in the entity to return in the response + */ fields: rt.array(rt.string), + /** + * The page of objects to return + */ page: NumberFromString, + /** + * The number of objects to include in each page + */ perPage: NumberFromString, + /** + * An Elasticsearch simple_query_string + */ search: rt.string, + /** + * The fields to perform the simple_query_string parsed query against + */ searchFields: rt.array(rt.string), + /** + * The field to use for sorting the found objects. + */ sortField: rt.string, + /** + * The order to sort by + */ sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), + owner: rt.string, }); export const SubCaseResponseRt = rt.intersection([ @@ -78,3 +110,4 @@ export type SubCasesResponse = rt.TypeOf; export type SubCasesFindResponse = rt.TypeOf; export type SubCasePatchRequest = rt.TypeOf; export type SubCasesPatchRequest = rt.TypeOf; +export type SubCasesFindRequest = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/cases/user_actions.ts b/x-pack/plugins/cases/common/api/cases/user_actions.ts index 7188ee44efa93..03912c550d77a 100644 --- a/x-pack/plugins/cases/common/api/cases/user_actions.ts +++ b/x-pack/plugins/cases/common/api/cases/user_actions.ts @@ -6,6 +6,7 @@ */ import * as rt from 'io-ts'; +import { OWNER_FIELD } from './constants'; import { UserRT } from '../user'; @@ -22,6 +23,7 @@ const UserActionFieldTypeRt = rt.union([ rt.literal('status'), rt.literal('settings'), rt.literal('sub_case'), + rt.literal(OWNER_FIELD), ]); const UserActionFieldRt = rt.array(UserActionFieldTypeRt); const UserActionRt = rt.union([ @@ -40,6 +42,7 @@ const CaseUserActionBasicRT = rt.type({ action_by: UserRT, new_value: rt.union([rt.string, rt.null]), old_value: rt.union([rt.string, rt.null]), + owner: rt.string, }); const CaseUserActionResponseRT = rt.intersection([ @@ -58,6 +61,7 @@ export const CaseUserActionsResponseRt = rt.array(CaseUserActionResponseRT); export type CaseUserActionAttributes = rt.TypeOf; export type CaseUserActionsResponse = rt.TypeOf; +export type CaseUserActionResponse = rt.TypeOf; export type UserAction = rt.TypeOf; export type UserActionField = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/connectors/mappings.ts b/x-pack/plugins/cases/common/api/connectors/mappings.ts index 3d2013af47688..e0fdd2d7e62dc 100644 --- a/x-pack/plugins/cases/common/api/connectors/mappings.ts +++ b/x-pack/plugins/cases/common/api/connectors/mappings.ts @@ -31,6 +31,7 @@ export const ConnectorMappingsAttributesRT = rt.type({ export const ConnectorMappingsRt = rt.type({ mappings: rt.array(ConnectorMappingsAttributesRT), + owner: rt.string, }); export type ConnectorMappingsAttributes = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/helpers.ts b/x-pack/plugins/cases/common/api/helpers.ts index 7ac686ce5c8dd..4647be5a91747 100644 --- a/x-pack/plugins/cases/common/api/helpers.ts +++ b/x-pack/plugins/cases/common/api/helpers.ts @@ -14,6 +14,7 @@ import { SUB_CASES_URL, CASE_PUSH_URL, SUB_CASE_USER_ACTIONS_URL, + CASE_CONFIGURE_DETAILS_URL, CASE_ALERTS_URL, } from '../constants'; @@ -49,6 +50,10 @@ export const getCasePushUrl = (caseId: string, connectorId: string): string => { return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId); }; +export const getCaseConfigurationDetailsUrl = (configureID: string): string => { + return CASE_CONFIGURE_DETAILS_URL.replace('{configuration_id}', configureID); +}; + export const getCasesFromAlertsUrl = (alertId: string): string => { return CASE_ALERTS_URL.replace('{alert_id}', alertId); }; diff --git a/x-pack/plugins/cases/common/api/runtime_types.ts b/x-pack/plugins/cases/common/api/runtime_types.ts index f608b7effe969..361786985c6de 100644 --- a/x-pack/plugins/cases/common/api/runtime_types.ts +++ b/x-pack/plugins/cases/common/api/runtime_types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { omit } from 'lodash'; import { either, fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -13,6 +14,9 @@ import { isObject } from 'lodash/fp'; type ErrorFactory = (message: string) => Error; +export const OmitProp = (o: O, k: K): Omit => + omit(o, k); + /** * @deprecated Use packages/kbn-securitysolution-io-ts-utils/src/format_errors/index.ts * Bug fix for the TODO is in the format_errors package @@ -68,7 +72,9 @@ const getExcessProps = (props: rt.Props, r: Record): string[] = return ex; }; -export function excess>(codec: C): C { +export function excess | rt.PartialType>( + codec: C +): C { const r = new rt.InterfaceType( codec.name, codec.is, diff --git a/x-pack/plugins/cases/common/api/saved_object.ts b/x-pack/plugins/cases/common/api/saved_object.ts index e0ae4ee82c490..2ed6ec2acdfe4 100644 --- a/x-pack/plugins/cases/common/api/saved_object.ts +++ b/x-pack/plugins/cases/common/api/saved_object.ts @@ -23,16 +23,49 @@ export const NumberFromString = new rt.Type( const ReferenceRt = rt.type({ id: rt.string, type: rt.string }); export const SavedObjectFindOptionsRt = rt.partial({ + /** + * The default operator to use for the simple_query_string + */ defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + /** + * The operator for controlling the logic of the `hasReference` field + */ hasReferenceOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + /** + * Filter by objects that have an association to another object + */ hasReference: rt.union([rt.array(ReferenceRt), ReferenceRt]), + /** + * The fields to return in the attributes key of the response + */ fields: rt.array(rt.string), + /** + * The filter is a KQL string with the caveat that if you filter with an attribute from your saved object type, it should look like that: savedObjectType.attributes.title: "myTitle". However, If you use a root attribute of a saved object such as updated_at, you will have to define your filter like that: savedObjectType.updated_at > 2018-12-22 + */ filter: rt.string, + /** + * The page of objects to return + */ page: NumberFromString, + /** + * The number of objects to return for a page + */ perPage: NumberFromString, + /** + * An Elasticsearch simple_query_string query that filters the objects in the response + */ search: rt.string, + /** + * The fields to perform the simple_query_string parsed query against + */ searchFields: rt.array(rt.string), + /** + * Sorts the response. Includes "root" and "type" fields. "root" fields exist for all saved objects, such as "updated_at". "type" fields are specific to an object type, such as fields returned in the attributes key of the response. When a single type is defined in the type parameter, the "root" and "type" fields are allowed, and validity checks are made in that order. When multiple types are defined in the type parameter, only "root" fields are allowed + */ sortField: rt.string, + /** + * Order to sort the response + */ sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), }); diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 966305524c059..72c21aa12dcf2 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -9,6 +9,24 @@ export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz'; export const APP_ID = 'cases'; +export const CASE_SAVED_OBJECT = 'cases'; +export const CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT = 'cases-connector-mappings'; +export const SUB_CASE_SAVED_OBJECT = 'cases-sub-case'; +export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions'; +export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments'; +export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure'; + +/** + * If more values are added here please also add them here: x-pack/test/case_api_integration/common/fixtures/plugins + */ +export const SAVED_OBJECT_TYPES = [ + CASE_SAVED_OBJECT, + CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + CASE_USER_ACTION_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + CASE_CONFIGURE_SAVED_OBJECT, +]; + /** * Case routes */ @@ -16,6 +34,7 @@ export const APP_ID = 'cases'; export const CASES_URL = '/api/cases'; export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; +export const CASE_CONFIGURE_DETAILS_URL = `${CASES_URL}/configure/{configuration_id}`; export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; export const SUB_CASES_PATCH_DEL_URL = `${CASES_URL}/sub_cases`; @@ -57,7 +76,19 @@ export const SUPPORTED_CONNECTORS = [ export const MAX_ALERTS_PER_SUB_CASE = 5000; export const MAX_GENERATED_ALERTS_PER_SUB_CASE = 50; +/** + * This must be the same value that the security solution plugin uses to define the case kind when it registers the + * feature for the 7.13 migration only. + * + * This variable is being also used by test files and mocks. + */ +export const SECURITY_SOLUTION_OWNER = 'securitySolution'; + /** * This flag governs enabling the case as a connector feature. It is disabled by default as the feature is not complete. */ export const ENABLE_CASE_CONNECTOR = false; + +if (ENABLE_CASE_CONNECTOR) { + SAVED_OBJECT_TYPES.push(SUB_CASE_SAVED_OBJECT); +} diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 43e3453500b17..284f5e706292c 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -58,6 +58,7 @@ export interface CaseExternalService { interface BasicCase { id: string; + owner: string; closedAt: string | null; closedBy: ElasticUser | null; comments: Comment[]; @@ -129,7 +130,7 @@ export interface ElasticUser { export interface FetchCasesProps extends ApiProps { queryParams?: QueryParams; - filterOptions?: FilterOptions; + filterOptions?: FilterOptions & { owner: string[] }; } export interface ApiProps { diff --git a/x-pack/plugins/cases/docs/README.md b/x-pack/plugins/cases/docs/README.md new file mode 100644 index 0000000000000..85482d98dc509 --- /dev/null +++ b/x-pack/plugins/cases/docs/README.md @@ -0,0 +1,37 @@ +# Cases Client API Docs + +This directory contains generated docs using `typedoc` for the cases client API that can be called from other server +plugins. This README will describe how to generate a new version of these markdown docs in the event that new methods +or parameters are added. + +## TypeDoc Info + +See more info at: +and: for the markdown plugin + +## Install dependencies + +```bash +yarn global add typedoc typedoc-plugin-markdown +``` + +## Generate the docs + +```bash +cd x-pack/plugins/cases/docs +npx typedoc --options cases_client_typedoc.json +``` + +After running the above commands the files in the `server` directory will be updated to match the new tsdocs. +If additional markdown directory should be created we can create a new typedoc configuration file and adjust the `out` +directory accordingly. + +## Troubleshooting + +If you run into tsc errors that seem unrelated to the cases plugin try executing these commands before running `typedoc` + +```bash +cd +npx yarn kbn bootstrap +node scripts/build_ts_refs.js --clean --no-cache +``` diff --git a/x-pack/plugins/cases/docs/cases_client/cases_client_api.md b/x-pack/plugins/cases/docs/cases_client/cases_client_api.md new file mode 100644 index 0000000000000..d7e75af3142e6 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/cases_client_api.md @@ -0,0 +1,22 @@ +Cases Client API Interface + +# Cases Client API Interface + +## Table of contents + +### Modules + +- [attachments/add](modules/attachments_add.md) +- [attachments/client](modules/attachments_client.md) +- [attachments/delete](modules/attachments_delete.md) +- [attachments/get](modules/attachments_get.md) +- [attachments/update](modules/attachments_update.md) +- [cases/client](modules/cases_client.md) +- [cases/get](modules/cases_get.md) +- [cases/push](modules/cases_push.md) +- [client](modules/client.md) +- [configure/client](modules/configure_client.md) +- [stats/client](modules/stats_client.md) +- [sub\_cases/client](modules/sub_cases_client.md) +- [typedoc\_interfaces](modules/typedoc_interfaces.md) +- [user\_actions/client](modules/user_actions_client.md) diff --git a/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md b/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md new file mode 100644 index 0000000000000..98e2f284da4a6 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/classes/client.casesclient.md @@ -0,0 +1,178 @@ +[Cases Client API Interface](../cases_client_api.md) / [client](../modules/client.md) / CasesClient + +# Class: CasesClient + +[client](../modules/client.md).CasesClient + +Client wrapper that contains accessor methods for individual entities within the cases system. + +## Table of contents + +### Constructors + +- [constructor](client.casesclient.md#constructor) + +### Properties + +- [\_attachments](client.casesclient.md#_attachments) +- [\_cases](client.casesclient.md#_cases) +- [\_casesClientInternal](client.casesclient.md#_casesclientinternal) +- [\_configure](client.casesclient.md#_configure) +- [\_stats](client.casesclient.md#_stats) +- [\_subCases](client.casesclient.md#_subcases) +- [\_userActions](client.casesclient.md#_useractions) + +### Accessors + +- [attachments](client.casesclient.md#attachments) +- [cases](client.casesclient.md#cases) +- [configure](client.casesclient.md#configure) +- [stats](client.casesclient.md#stats) +- [subCases](client.casesclient.md#subcases) +- [userActions](client.casesclient.md#useractions) + +## Constructors + +### constructor + +\+ **new CasesClient**(`args`: CasesClientArgs): [*CasesClient*](client.casesclient.md) + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `args` | CasesClientArgs | + +**Returns:** [*CasesClient*](client.casesclient.md) + +Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L28) + +## Properties + +### \_attachments + +โ€ข `Private` `Readonly` **\_attachments**: [*AttachmentsSubClient*](../interfaces/attachments_client.attachmentssubclient.md) + +Defined in: [client.ts:24](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L24) + +___ + +### \_cases + +โ€ข `Private` `Readonly` **\_cases**: [*CasesSubClient*](../interfaces/cases_client.casessubclient.md) + +Defined in: [client.ts:23](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L23) + +___ + +### \_casesClientInternal + +โ€ข `Private` `Readonly` **\_casesClientInternal**: *CasesClientInternal* + +Defined in: [client.ts:22](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L22) + +___ + +### \_configure + +โ€ข `Private` `Readonly` **\_configure**: [*ConfigureSubClient*](../interfaces/configure_client.configuresubclient.md) + +Defined in: [client.ts:27](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L27) + +___ + +### \_stats + +โ€ข `Private` `Readonly` **\_stats**: [*StatsSubClient*](../interfaces/stats_client.statssubclient.md) + +Defined in: [client.ts:28](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L28) + +___ + +### \_subCases + +โ€ข `Private` `Readonly` **\_subCases**: [*SubCasesClient*](../interfaces/sub_cases_client.subcasesclient.md) + +Defined in: [client.ts:26](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L26) + +___ + +### \_userActions + +โ€ข `Private` `Readonly` **\_userActions**: [*UserActionsSubClient*](../interfaces/user_actions_client.useractionssubclient.md) + +Defined in: [client.ts:25](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L25) + +## Accessors + +### attachments + +โ€ข get **attachments**(): [*AttachmentsSubClient*](../interfaces/attachments_client.attachmentssubclient.md) + +Retrieves an interface for interacting with attachments (comments) entities. + +**Returns:** [*AttachmentsSubClient*](../interfaces/attachments_client.attachmentssubclient.md) + +Defined in: [client.ts:50](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L50) + +___ + +### cases + +โ€ข get **cases**(): [*CasesSubClient*](../interfaces/cases_client.casessubclient.md) + +Retrieves an interface for interacting with cases entities. + +**Returns:** [*CasesSubClient*](../interfaces/cases_client.casessubclient.md) + +Defined in: [client.ts:43](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L43) + +___ + +### configure + +โ€ข get **configure**(): [*ConfigureSubClient*](../interfaces/configure_client.configuresubclient.md) + +Retrieves an interface for interacting with the configuration of external connectors for the plugin entities. + +**Returns:** [*ConfigureSubClient*](../interfaces/configure_client.configuresubclient.md) + +Defined in: [client.ts:76](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L76) + +___ + +### stats + +โ€ข get **stats**(): [*StatsSubClient*](../interfaces/stats_client.statssubclient.md) + +Retrieves an interface for retrieving statistics related to the cases entities. + +**Returns:** [*StatsSubClient*](../interfaces/stats_client.statssubclient.md) + +Defined in: [client.ts:83](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L83) + +___ + +### subCases + +โ€ข get **subCases**(): [*SubCasesClient*](../interfaces/sub_cases_client.subcasesclient.md) + +Retrieves an interface for interacting with the case as a connector entities. + +Currently this functionality is disabled and will throw an error if this function is called. + +**Returns:** [*SubCasesClient*](../interfaces/sub_cases_client.subcasesclient.md) + +Defined in: [client.ts:66](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L66) + +___ + +### userActions + +โ€ข get **userActions**(): [*UserActionsSubClient*](../interfaces/user_actions_client.useractionssubclient.md) + +Retrieves an interface for interacting with the user actions associated with the plugin entities. + +**Returns:** [*UserActionsSubClient*](../interfaces/user_actions_client.useractionssubclient.md) + +Defined in: [client.ts:57](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/client.ts#L57) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md new file mode 100644 index 0000000000000..1bbca9167a5c2 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_add.addargs.md @@ -0,0 +1,34 @@ +[Cases Client API Interface](../cases_client_api.md) / [attachments/add](../modules/attachments_add.md) / AddArgs + +# Interface: AddArgs + +[attachments/add](../modules/attachments_add.md).AddArgs + +The arguments needed for creating a new attachment to a case. + +## Table of contents + +### Properties + +- [caseId](attachments_add.addargs.md#caseid) +- [comment](attachments_add.addargs.md#comment) + +## Properties + +### caseId + +โ€ข **caseId**: *string* + +The case ID that this attachment will be associated with + +Defined in: [attachments/add.ts:308](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/add.ts#L308) + +___ + +### comment + +โ€ข **comment**: { `comment`: *string* ; `owner`: *string* ; `type`: user } \| { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } + +The attachment values. + +Defined in: [attachments/add.ts:312](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/add.ts#L312) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md new file mode 100644 index 0000000000000..e9f65bcf9915a --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_client.attachmentssubclient.md @@ -0,0 +1,147 @@ +[Cases Client API Interface](../cases_client_api.md) / [attachments/client](../modules/attachments_client.md) / AttachmentsSubClient + +# Interface: AttachmentsSubClient + +[attachments/client](../modules/attachments_client.md).AttachmentsSubClient + +API for interacting with the attachments to a case. + +## Table of contents + +### Methods + +- [add](attachments_client.attachmentssubclient.md#add) +- [delete](attachments_client.attachmentssubclient.md#delete) +- [deleteAll](attachments_client.attachmentssubclient.md#deleteall) +- [find](attachments_client.attachmentssubclient.md#find) +- [get](attachments_client.attachmentssubclient.md#get) +- [getAll](attachments_client.attachmentssubclient.md#getall) +- [update](attachments_client.attachmentssubclient.md#update) + +## Methods + +### add + +โ–ธ **add**(`params`: [*AddArgs*](attachments_add.addargs.md)): *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> + +Adds an attachment to a case. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params` | [*AddArgs*](attachments_add.addargs.md) | + +**Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> + +Defined in: [attachments/client.ts:25](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/client.ts#L25) + +___ + +### delete + +โ–ธ **delete**(`deleteArgs`: [*DeleteArgs*](attachments_delete.deleteargs.md)): *Promise* + +Deletes a single attachment for a specific case. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `deleteArgs` | [*DeleteArgs*](attachments_delete.deleteargs.md) | + +**Returns:** *Promise* + +Defined in: [attachments/client.ts:33](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/client.ts#L33) + +___ + +### deleteAll + +โ–ธ **deleteAll**(`deleteAllArgs`: [*DeleteAllArgs*](attachments_delete.deleteallargs.md)): *Promise* + +Deletes all attachments associated with a single case. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `deleteAllArgs` | [*DeleteAllArgs*](attachments_delete.deleteallargs.md) | + +**Returns:** *Promise* + +Defined in: [attachments/client.ts:29](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/client.ts#L29) + +___ + +### find + +โ–ธ **find**(`findArgs`: [*FindArgs*](attachments_get.findargs.md)): *Promise*<[*ICommentsResponse*](typedoc_interfaces.icommentsresponse.md)\> + +Retrieves all comments matching the search criteria. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `findArgs` | [*FindArgs*](attachments_get.findargs.md) | + +**Returns:** *Promise*<[*ICommentsResponse*](typedoc_interfaces.icommentsresponse.md)\> + +Defined in: [attachments/client.ts:37](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/client.ts#L37) + +___ + +### get + +โ–ธ **get**(`getArgs`: [*GetArgs*](attachments_get.getargs.md)): *Promise*<{ `comment`: *string* ; `owner`: *string* ; `type`: user } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* }\> + +Retrieves a single attachment for a case. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `getArgs` | [*GetArgs*](attachments_get.getargs.md) | + +**Returns:** *Promise*<{ `comment`: *string* ; `owner`: *string* ; `type`: user } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* }\> + +Defined in: [attachments/client.ts:45](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/client.ts#L45) + +___ + +### getAll + +โ–ธ **getAll**(`getAllArgs`: [*GetAllArgs*](attachments_get.getallargs.md)): *Promise*<[*IAllCommentsResponse*](typedoc_interfaces.iallcommentsresponse.md)\> + +Gets all attachments for a single case. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `getAllArgs` | [*GetAllArgs*](attachments_get.getallargs.md) | + +**Returns:** *Promise*<[*IAllCommentsResponse*](typedoc_interfaces.iallcommentsresponse.md)\> + +Defined in: [attachments/client.ts:41](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/client.ts#L41) + +___ + +### update + +โ–ธ **update**(`updateArgs`: [*UpdateArgs*](attachments_update.updateargs.md)): *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> + +Updates a specific attachment. + +The request must include all fields for the attachment. Even the fields that are not changing. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `updateArgs` | [*UpdateArgs*](attachments_update.updateargs.md) | + +**Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> + +Defined in: [attachments/client.ts:51](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/client.ts#L51) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md new file mode 100644 index 0000000000000..26b00ac6e037e --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteallargs.md @@ -0,0 +1,34 @@ +[Cases Client API Interface](../cases_client_api.md) / [attachments/delete](../modules/attachments_delete.md) / DeleteAllArgs + +# Interface: DeleteAllArgs + +[attachments/delete](../modules/attachments_delete.md).DeleteAllArgs + +Parameters for deleting all comments of a case or sub case. + +## Table of contents + +### Properties + +- [caseID](attachments_delete.deleteallargs.md#caseid) +- [subCaseID](attachments_delete.deleteallargs.md#subcaseid) + +## Properties + +### caseID + +โ€ข **caseID**: *string* + +The case ID to delete all attachments for + +Defined in: [attachments/delete.ts:26](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/delete.ts#L26) + +___ + +### subCaseID + +โ€ข `Optional` **subCaseID**: *string* + +If specified the caseID will be ignored and this value will be used to find a sub case for deleting all the attachments + +Defined in: [attachments/delete.ts:30](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/delete.ts#L30) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md new file mode 100644 index 0000000000000..f9d4038eb417a --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_delete.deleteargs.md @@ -0,0 +1,45 @@ +[Cases Client API Interface](../cases_client_api.md) / [attachments/delete](../modules/attachments_delete.md) / DeleteArgs + +# Interface: DeleteArgs + +[attachments/delete](../modules/attachments_delete.md).DeleteArgs + +Parameters for deleting a single attachment of a case or sub case. + +## Table of contents + +### Properties + +- [attachmentID](attachments_delete.deleteargs.md#attachmentid) +- [caseID](attachments_delete.deleteargs.md#caseid) +- [subCaseID](attachments_delete.deleteargs.md#subcaseid) + +## Properties + +### attachmentID + +โ€ข **attachmentID**: *string* + +The attachment ID to delete + +Defined in: [attachments/delete.ts:44](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/delete.ts#L44) + +___ + +### caseID + +โ€ข **caseID**: *string* + +The case ID to delete an attachment from + +Defined in: [attachments/delete.ts:40](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/delete.ts#L40) + +___ + +### subCaseID + +โ€ข `Optional` **subCaseID**: *string* + +If specified the caseID will be ignored and this value will be used to find a sub case for deleting the attachment + +Defined in: [attachments/delete.ts:48](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/delete.ts#L48) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md new file mode 100644 index 0000000000000..dbbac0065be85 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.findargs.md @@ -0,0 +1,51 @@ +[Cases Client API Interface](../cases_client_api.md) / [attachments/get](../modules/attachments_get.md) / FindArgs + +# Interface: FindArgs + +[attachments/get](../modules/attachments_get.md).FindArgs + +Parameters for finding attachments of a case + +## Table of contents + +### Properties + +- [caseID](attachments_get.findargs.md#caseid) +- [queryParams](attachments_get.findargs.md#queryparams) + +## Properties + +### caseID + +โ€ข **caseID**: *string* + +The case ID for finding associated attachments + +Defined in: [attachments/get.ts:48](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/get.ts#L48) + +___ + +### queryParams + +โ€ข `Optional` **queryParams**: *object* + +Optional parameters for filtering the returned attachments + +#### Type declaration + +| Name | Type | +| :------ | :------ | +| `defaultSearchOperator` | *undefined* \| ``"AND"`` \| ``"OR"`` | +| `fields` | *undefined* \| *string*[] | +| `filter` | *undefined* \| *string* | +| `hasReference` | *undefined* \| { `id`: *string* ; `type`: *string* } \| { `id`: *string* ; `type`: *string* }[] | +| `hasReferenceOperator` | *undefined* \| ``"AND"`` \| ``"OR"`` | +| `page` | *undefined* \| *number* | +| `perPage` | *undefined* \| *number* | +| `search` | *undefined* \| *string* | +| `searchFields` | *undefined* \| *string*[] | +| `sortField` | *undefined* \| *string* | +| `sortOrder` | *undefined* \| ``"desc"`` \| ``"asc"`` | +| `subCaseId` | *undefined* \| *string* | + +Defined in: [attachments/get.ts:52](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/get.ts#L52) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md new file mode 100644 index 0000000000000..dbd66291e22de --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getallargs.md @@ -0,0 +1,45 @@ +[Cases Client API Interface](../cases_client_api.md) / [attachments/get](../modules/attachments_get.md) / GetAllArgs + +# Interface: GetAllArgs + +[attachments/get](../modules/attachments_get.md).GetAllArgs + +Parameters for retrieving all attachments of a case + +## Table of contents + +### Properties + +- [caseID](attachments_get.getallargs.md#caseid) +- [includeSubCaseComments](attachments_get.getallargs.md#includesubcasecomments) +- [subCaseID](attachments_get.getallargs.md#subcaseid) + +## Properties + +### caseID + +โ€ข **caseID**: *string* + +The case ID to retrieve all attachments for + +Defined in: [attachments/get.ts:62](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/get.ts#L62) + +___ + +### includeSubCaseComments + +โ€ข `Optional` **includeSubCaseComments**: *boolean* + +Optionally include the attachments associated with a sub case + +Defined in: [attachments/get.ts:66](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/get.ts#L66) + +___ + +### subCaseID + +โ€ข `Optional` **subCaseID**: *string* + +If included the case ID will be ignored and the attachments will be retrieved from the specified ID of the sub case + +Defined in: [attachments/get.ts:70](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/get.ts#L70) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md new file mode 100644 index 0000000000000..abfd4bb5958d3 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_get.getargs.md @@ -0,0 +1,32 @@ +[Cases Client API Interface](../cases_client_api.md) / [attachments/get](../modules/attachments_get.md) / GetArgs + +# Interface: GetArgs + +[attachments/get](../modules/attachments_get.md).GetArgs + +## Table of contents + +### Properties + +- [attachmentID](attachments_get.getargs.md#attachmentid) +- [caseID](attachments_get.getargs.md#caseid) + +## Properties + +### attachmentID + +โ€ข **attachmentID**: *string* + +The ID of the attachment to retrieve + +Defined in: [attachments/get.ts:81](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/get.ts#L81) + +___ + +### caseID + +โ€ข **caseID**: *string* + +The ID of the case to retrieve an attachment from + +Defined in: [attachments/get.ts:77](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/get.ts#L77) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md new file mode 100644 index 0000000000000..b571067175f62 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/attachments_update.updateargs.md @@ -0,0 +1,45 @@ +[Cases Client API Interface](../cases_client_api.md) / [attachments/update](../modules/attachments_update.md) / UpdateArgs + +# Interface: UpdateArgs + +[attachments/update](../modules/attachments_update.md).UpdateArgs + +Parameters for updating a single attachment + +## Table of contents + +### Properties + +- [caseID](attachments_update.updateargs.md#caseid) +- [subCaseID](attachments_update.updateargs.md#subcaseid) +- [updateRequest](attachments_update.updateargs.md#updaterequest) + +## Properties + +### caseID + +โ€ข **caseID**: *string* + +The ID of the case that is associated with this attachment + +Defined in: [attachments/update.ts:29](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/update.ts#L29) + +___ + +### subCaseID + +โ€ข `Optional` **subCaseID**: *string* + +The ID of a sub case, if specified a sub case will be searched for to perform the attachment update instead of on a case + +Defined in: [attachments/update.ts:37](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/update.ts#L37) + +___ + +### updateRequest + +โ€ข **updateRequest**: { `comment`: *string* ; `owner`: *string* ; `type`: user } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `id`: *string* ; `version`: *string* } + +The full attachment request with the fields updated with appropriate values + +Defined in: [attachments/update.ts:33](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/attachments/update.ts#L33) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md new file mode 100644 index 0000000000000..e7d7dea34d0ad --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_client.casessubclient.md @@ -0,0 +1,189 @@ +[Cases Client API Interface](../cases_client_api.md) / [cases/client](../modules/cases_client.md) / CasesSubClient + +# Interface: CasesSubClient + +[cases/client](../modules/cases_client.md).CasesSubClient + +API for interacting with the cases entities. + +## Table of contents + +### Methods + +- [create](cases_client.casessubclient.md#create) +- [delete](cases_client.casessubclient.md#delete) +- [find](cases_client.casessubclient.md#find) +- [get](cases_client.casessubclient.md#get) +- [getCaseIDsByAlertID](cases_client.casessubclient.md#getcaseidsbyalertid) +- [getReporters](cases_client.casessubclient.md#getreporters) +- [getTags](cases_client.casessubclient.md#gettags) +- [push](cases_client.casessubclient.md#push) +- [update](cases_client.casessubclient.md#update) + +## Methods + +### create + +โ–ธ **create**(`data`: [*ICasePostRequest*](typedoc_interfaces.icasepostrequest.md)): *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> + +Creates a case. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `data` | [*ICasePostRequest*](typedoc_interfaces.icasepostrequest.md) | + +**Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> + +Defined in: [cases/client.ts:48](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/client.ts#L48) + +___ + +### delete + +โ–ธ **delete**(`ids`: *string*[]): *Promise* + +Delete a case and all its comments. + +**`params`** ids an array of case IDs to delete + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `ids` | *string*[] | + +**Returns:** *Promise* + +Defined in: [cases/client.ts:72](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/client.ts#L72) + +___ + +### find + +โ–ธ **find**(`params`: [*ICasesFindRequest*](typedoc_interfaces.icasesfindrequest.md)): *Promise*<[*ICasesFindResponse*](typedoc_interfaces.icasesfindresponse.md)\> + +Returns cases that match the search criteria. + +If the `owner` field is left empty then all the cases that the user has access to will be returned. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params` | [*ICasesFindRequest*](typedoc_interfaces.icasesfindrequest.md) | + +**Returns:** *Promise*<[*ICasesFindResponse*](typedoc_interfaces.icasesfindresponse.md)\> + +Defined in: [cases/client.ts:54](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/client.ts#L54) + +___ + +### get + +โ–ธ **get**(`params`: [*GetParams*](cases_get.getparams.md)): *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> + +Retrieves a single case with the specified ID. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params` | [*GetParams*](cases_get.getparams.md) | + +**Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> + +Defined in: [cases/client.ts:58](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/client.ts#L58) + +___ + +### getCaseIDsByAlertID + +โ–ธ **getCaseIDsByAlertID**(`params`: [*CaseIDsByAlertIDParams*](cases_get.caseidsbyalertidparams.md)): *Promise* + +Retrieves the case IDs given a single alert ID + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params` | [*CaseIDsByAlertIDParams*](cases_get.caseidsbyalertidparams.md) | + +**Returns:** *Promise* + +Defined in: [cases/client.ts:84](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/client.ts#L84) + +___ + +### getReporters + +โ–ธ **getReporters**(`params`: { `owner`: *undefined* \| *string* \| *string*[] }): *Promise*<{ `email`: *undefined* \| ``null`` \| *string* ; `full_name`: *undefined* \| ``null`` \| *string* ; `username`: *undefined* \| ``null`` \| *string* }[]\> + +Retrieves all the reporters across all accessible cases. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params` | *object* | +| `params.owner` | *undefined* \| *string* \| *string*[] | + +**Returns:** *Promise*<{ `email`: *undefined* \| ``null`` \| *string* ; `full_name`: *undefined* \| ``null`` \| *string* ; `username`: *undefined* \| ``null`` \| *string* }[]\> + +Defined in: [cases/client.ts:80](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/client.ts#L80) + +___ + +### getTags + +โ–ธ **getTags**(`params`: { `owner`: *undefined* \| *string* \| *string*[] }): *Promise* + +Retrieves all the tags across all cases the user making the request has access to. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params` | *object* | +| `params.owner` | *undefined* \| *string* \| *string*[] | + +**Returns:** *Promise* + +Defined in: [cases/client.ts:76](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/client.ts#L76) + +___ + +### push + +โ–ธ **push**(`args`: [*PushParams*](cases_push.pushparams.md)): *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> + +Pushes a specific case to an external system. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `args` | [*PushParams*](cases_push.pushparams.md) | + +**Returns:** *Promise*<[*ICaseResponse*](typedoc_interfaces.icaseresponse.md)\> + +Defined in: [cases/client.ts:62](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/client.ts#L62) + +___ + +### update + +โ–ธ **update**(`cases`: [*ICasesPatchRequest*](typedoc_interfaces.icasespatchrequest.md)): *Promise*<[*ICasesResponse*](typedoc_interfaces.icasesresponse.md)\> + +Update the specified cases with the passed in values. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `cases` | [*ICasesPatchRequest*](typedoc_interfaces.icasespatchrequest.md) | + +**Returns:** *Promise*<[*ICasesResponse*](typedoc_interfaces.icasesresponse.md)\> + +Defined in: [cases/client.ts:66](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/client.ts#L66) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md new file mode 100644 index 0000000000000..1b8abba1a4071 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.caseidsbyalertidparams.md @@ -0,0 +1,40 @@ +[Cases Client API Interface](../cases_client_api.md) / [cases/get](../modules/cases_get.md) / CaseIDsByAlertIDParams + +# Interface: CaseIDsByAlertIDParams + +[cases/get](../modules/cases_get.md).CaseIDsByAlertIDParams + +Parameters for finding cases IDs using an alert ID + +## Table of contents + +### Properties + +- [alertID](cases_get.caseidsbyalertidparams.md#alertid) +- [options](cases_get.caseidsbyalertidparams.md#options) + +## Properties + +### alertID + +โ€ข **alertID**: *string* + +The alert ID to search for + +Defined in: [cases/get.ts:47](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/get.ts#L47) + +___ + +### options + +โ€ข **options**: *object* + +The filtering options when searching for associated cases. + +#### Type declaration + +| Name | Type | +| :------ | :------ | +| `owner` | *undefined* \| *string* \| *string*[] | + +Defined in: [cases/get.ts:51](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/get.ts#L51) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md new file mode 100644 index 0000000000000..8c12b5533ac18 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_get.getparams.md @@ -0,0 +1,45 @@ +[Cases Client API Interface](../cases_client_api.md) / [cases/get](../modules/cases_get.md) / GetParams + +# Interface: GetParams + +[cases/get](../modules/cases_get.md).GetParams + +The parameters for retrieving a case + +## Table of contents + +### Properties + +- [id](cases_get.getparams.md#id) +- [includeComments](cases_get.getparams.md#includecomments) +- [includeSubCaseComments](cases_get.getparams.md#includesubcasecomments) + +## Properties + +### id + +โ€ข **id**: *string* + +Case ID + +Defined in: [cases/get.ts:122](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/get.ts#L122) + +___ + +### includeComments + +โ€ข `Optional` **includeComments**: *boolean* + +Whether to include the attachments for a case in the response + +Defined in: [cases/get.ts:126](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/get.ts#L126) + +___ + +### includeSubCaseComments + +โ€ข `Optional` **includeSubCaseComments**: *boolean* + +Whether to include the attachments for all children of a case in the response + +Defined in: [cases/get.ts:130](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/get.ts#L130) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md new file mode 100644 index 0000000000000..9f1810e4f0cc2 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/cases_push.pushparams.md @@ -0,0 +1,34 @@ +[Cases Client API Interface](../cases_client_api.md) / [cases/push](../modules/cases_push.md) / PushParams + +# Interface: PushParams + +[cases/push](../modules/cases_push.md).PushParams + +Parameters for pushing a case to an external system + +## Table of contents + +### Properties + +- [caseId](cases_push.pushparams.md#caseid) +- [connectorId](cases_push.pushparams.md#connectorid) + +## Properties + +### caseId + +โ€ข **caseId**: *string* + +The ID of a case + +Defined in: [cases/push.ts:53](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/push.ts#L53) + +___ + +### connectorId + +โ€ข **connectorId**: *string* + +The ID of an external system to push to + +Defined in: [cases/push.ts:57](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/push.ts#L57) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md new file mode 100644 index 0000000000000..9b3827a57a9d3 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/configure_client.configuresubclient.md @@ -0,0 +1,84 @@ +[Cases Client API Interface](../cases_client_api.md) / [configure/client](../modules/configure_client.md) / ConfigureSubClient + +# Interface: ConfigureSubClient + +[configure/client](../modules/configure_client.md).ConfigureSubClient + +This is the public API for interacting with the connector configuration for cases. + +## Table of contents + +### Methods + +- [create](configure_client.configuresubclient.md#create) +- [get](configure_client.configuresubclient.md#get) +- [getConnectors](configure_client.configuresubclient.md#getconnectors) +- [update](configure_client.configuresubclient.md#update) + +## Methods + +### create + +โ–ธ **create**(`configuration`: [*ICasesConfigureRequest*](typedoc_interfaces.icasesconfigurerequest.md)): *Promise*<[*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> + +Creates a configuration if one does not already exist. If one exists it is deleted and a new one is created. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `configuration` | [*ICasesConfigureRequest*](typedoc_interfaces.icasesconfigurerequest.md) | + +**Returns:** *Promise*<[*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> + +Defined in: [configure/client.ts:102](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/configure/client.ts#L102) + +___ + +### get + +โ–ธ **get**(`params`: { `owner`: *undefined* \| *string* \| *string*[] }): *Promise*<{} \| [*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> + +Retrieves the external connector configuration for a particular case owner. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params` | *object* | +| `params.owner` | *undefined* \| *string* \| *string*[] | + +**Returns:** *Promise*<{} \| [*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> + +Defined in: [configure/client.ts:84](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/configure/client.ts#L84) + +___ + +### getConnectors + +โ–ธ **getConnectors**(): *Promise* + +Retrieves the valid external connectors supported by the cases plugin. + +**Returns:** *Promise* + +Defined in: [configure/client.ts:88](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/configure/client.ts#L88) + +___ + +### update + +โ–ธ **update**(`configurationId`: *string*, `configurations`: [*ICasesConfigurePatch*](typedoc_interfaces.icasesconfigurepatch.md)): *Promise*<[*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> + +Updates a particular configuration with new values. + +#### Parameters + +| Name | Type | Description | +| :------ | :------ | :------ | +| `configurationId` | *string* | the ID of the configuration to update | +| `configurations` | [*ICasesConfigurePatch*](typedoc_interfaces.icasesconfigurepatch.md) | the new configuration parameters | + +**Returns:** *Promise*<[*ICasesConfigureResponse*](typedoc_interfaces.icasesconfigureresponse.md)\> + +Defined in: [configure/client.ts:95](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/configure/client.ts#L95) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md new file mode 100644 index 0000000000000..7e01205395277 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/stats_client.statssubclient.md @@ -0,0 +1,32 @@ +[Cases Client API Interface](../cases_client_api.md) / [stats/client](../modules/stats_client.md) / StatsSubClient + +# Interface: StatsSubClient + +[stats/client](../modules/stats_client.md).StatsSubClient + +Statistics API contract. + +## Table of contents + +### Methods + +- [getStatusTotalsByType](stats_client.statssubclient.md#getstatustotalsbytype) + +## Methods + +### getStatusTotalsByType + +โ–ธ **getStatusTotalsByType**(`params`: { `owner`: *undefined* \| *string* \| *string*[] }): *Promise*<{ `count_closed_cases`: *number* ; `count_in_progress_cases`: *number* ; `count_open_cases`: *number* }\> + +Retrieves the total number of open, closed, and in-progress cases. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params` | *object* | +| `params.owner` | *undefined* \| *string* \| *string*[] | + +**Returns:** *Promise*<{ `count_closed_cases`: *number* ; `count_in_progress_cases`: *number* ; `count_open_cases`: *number* }\> + +Defined in: [stats/client.ts:34](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/stats/client.ts#L34) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md new file mode 100644 index 0000000000000..76df26524b7b0 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/sub_cases_client.subcasesclient.md @@ -0,0 +1,89 @@ +[Cases Client API Interface](../cases_client_api.md) / [sub_cases/client](../modules/sub_cases_client.md) / SubCasesClient + +# Interface: SubCasesClient + +[sub_cases/client](../modules/sub_cases_client.md).SubCasesClient + +The API routes for interacting with sub cases. + +## Table of contents + +### Methods + +- [delete](sub_cases_client.subcasesclient.md#delete) +- [find](sub_cases_client.subcasesclient.md#find) +- [get](sub_cases_client.subcasesclient.md#get) +- [update](sub_cases_client.subcasesclient.md#update) + +## Methods + +### delete + +โ–ธ **delete**(`ids`: *string*[]): *Promise* + +Deletes the specified entities and their attachments. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `ids` | *string*[] | + +**Returns:** *Promise* + +Defined in: [sub_cases/client.ts:60](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/sub_cases/client.ts#L60) + +___ + +### find + +โ–ธ **find**(`findArgs`: FindArgs): *Promise*<[*ISubCasesFindResponse*](typedoc_interfaces.isubcasesfindresponse.md)\> + +Retrieves the sub cases matching the search criteria. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `findArgs` | FindArgs | + +**Returns:** *Promise*<[*ISubCasesFindResponse*](typedoc_interfaces.isubcasesfindresponse.md)\> + +Defined in: [sub_cases/client.ts:64](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/sub_cases/client.ts#L64) + +___ + +### get + +โ–ธ **get**(`getArgs`: GetArgs): *Promise*<[*ISubCaseResponse*](typedoc_interfaces.isubcaseresponse.md)\> + +Retrieves a single sub case. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `getArgs` | GetArgs | + +**Returns:** *Promise*<[*ISubCaseResponse*](typedoc_interfaces.isubcaseresponse.md)\> + +Defined in: [sub_cases/client.ts:68](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/sub_cases/client.ts#L68) + +___ + +### update + +โ–ธ **update**(`subCases`: { `subCases`: { `status`: *undefined* \| open \| *any*[*any*] \| closed } & { id: string; version: string; }[] }): *Promise*<[*ISubCasesResponse*](typedoc_interfaces.isubcasesresponse.md)\> + +Updates the specified sub cases to the new values included in the request. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `subCases` | *object* | +| `subCases.subCases` | { `status`: *undefined* \| open \| *any*[*any*] \| closed } & { id: string; version: string; }[] | + +**Returns:** *Promise*<[*ISubCasesResponse*](typedoc_interfaces.isubcasesresponse.md)\> + +Defined in: [sub_cases/client.ts:72](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/sub_cases/client.ts#L72) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.iallcommentsresponse.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.iallcommentsresponse.md new file mode 100644 index 0000000000000..06322bb51e2ad --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.iallcommentsresponse.md @@ -0,0 +1,11 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / IAllCommentsResponse + +# Interface: IAllCommentsResponse + +[typedoc_interfaces](../modules/typedoc_interfaces.md).IAllCommentsResponse + +## Hierarchy + +- *AllCommentsResponse* + + โ†ณ **IAllCommentsResponse** diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasepostrequest.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasepostrequest.md new file mode 100644 index 0000000000000..70533a15fe616 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasepostrequest.md @@ -0,0 +1,88 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICasePostRequest + +# Interface: ICasePostRequest + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICasePostRequest + +These are simply to make typedoc not attempt to expand the type aliases. If it attempts to expand them +the docs are huge. + +## Hierarchy + +- *CasePostRequest* + + โ†ณ **ICasePostRequest** + +## Table of contents + +### Properties + +- [connector](typedoc_interfaces.icasepostrequest.md#connector) +- [description](typedoc_interfaces.icasepostrequest.md#description) +- [owner](typedoc_interfaces.icasepostrequest.md#owner) +- [settings](typedoc_interfaces.icasepostrequest.md#settings) +- [tags](typedoc_interfaces.icasepostrequest.md#tags) +- [title](typedoc_interfaces.icasepostrequest.md#title) +- [type](typedoc_interfaces.icasepostrequest.md#type) + +## Properties + +### connector + +โ€ข **connector**: { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { issueType: string \| null; priority: string \| null; parent: string \| null; } ; `type`: jira } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { incidentTypes: string[] \| null; severityCode: string \| null; } ; `type`: resilient } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { impact: string \| null; severity: string \| null; urgency: string \| null; category: string \| null; subcategory: string \| null; } ; `type`: serviceNowITSM } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { category: string \| null; destIp: boolean \| null; malwareHash: boolean \| null; malwareUrl: boolean \| null; priority: string \| null; sourceIp: boolean \| null; subcategory: string \| null; } ; `type`: serviceNowSIR } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` ; `type`: none } + +Inherited from: CasePostRequest.connector + +___ + +### description + +โ€ข **description**: *string* + +Inherited from: CasePostRequest.description + +___ + +### owner + +โ€ข **owner**: *string* + +Inherited from: CasePostRequest.owner + +___ + +### settings + +โ€ข **settings**: *object* + +#### Type declaration + +| Name | Type | +| :------ | :------ | +| `syncAlerts` | *boolean* | + +Inherited from: CasePostRequest.settings + +___ + +### tags + +โ€ข **tags**: *string*[] + +Inherited from: CasePostRequest.tags + +___ + +### title + +โ€ข **title**: *string* + +Inherited from: CasePostRequest.title + +___ + +### type + +โ€ข **type**: *undefined* \| collection \| individual + +Inherited from: CasePostRequest.type diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icaseresponse.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icaseresponse.md new file mode 100644 index 0000000000000..5db55e5552473 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icaseresponse.md @@ -0,0 +1,228 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICaseResponse + +# Interface: ICaseResponse + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICaseResponse + +## Hierarchy + +- *CaseResponse* + + โ†ณ **ICaseResponse** + +## Table of contents + +### Properties + +- [closed\_at](typedoc_interfaces.icaseresponse.md#closed_at) +- [closed\_by](typedoc_interfaces.icaseresponse.md#closed_by) +- [comments](typedoc_interfaces.icaseresponse.md#comments) +- [connector](typedoc_interfaces.icaseresponse.md#connector) +- [created\_at](typedoc_interfaces.icaseresponse.md#created_at) +- [created\_by](typedoc_interfaces.icaseresponse.md#created_by) +- [description](typedoc_interfaces.icaseresponse.md#description) +- [external\_service](typedoc_interfaces.icaseresponse.md#external_service) +- [id](typedoc_interfaces.icaseresponse.md#id) +- [owner](typedoc_interfaces.icaseresponse.md#owner) +- [settings](typedoc_interfaces.icaseresponse.md#settings) +- [status](typedoc_interfaces.icaseresponse.md#status) +- [subCaseIds](typedoc_interfaces.icaseresponse.md#subcaseids) +- [subCases](typedoc_interfaces.icaseresponse.md#subcases) +- [tags](typedoc_interfaces.icaseresponse.md#tags) +- [title](typedoc_interfaces.icaseresponse.md#title) +- [totalAlerts](typedoc_interfaces.icaseresponse.md#totalalerts) +- [totalComment](typedoc_interfaces.icaseresponse.md#totalcomment) +- [type](typedoc_interfaces.icaseresponse.md#type) +- [updated\_at](typedoc_interfaces.icaseresponse.md#updated_at) +- [updated\_by](typedoc_interfaces.icaseresponse.md#updated_by) +- [version](typedoc_interfaces.icaseresponse.md#version) + +## Properties + +### closed\_at + +โ€ข **closed\_at**: ``null`` \| *string* + +Inherited from: CaseResponse.closed\_at + +___ + +### closed\_by + +โ€ข **closed\_by**: ``null`` \| { `email`: *undefined* \| ``null`` \| *string* ; `full_name`: *undefined* \| ``null`` \| *string* ; `username`: *undefined* \| ``null`` \| *string* } + +Inherited from: CaseResponse.closed\_by + +___ + +### comments + +โ€ข **comments**: *undefined* \| { `comment`: *string* ; `owner`: *string* ; `type`: user } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* }[] + +Inherited from: CaseResponse.comments + +___ + +### connector + +โ€ข **connector**: { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { issueType: string \| null; priority: string \| null; parent: string \| null; } ; `type`: jira } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { incidentTypes: string[] \| null; severityCode: string \| null; } ; `type`: resilient } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { impact: string \| null; severity: string \| null; urgency: string \| null; category: string \| null; subcategory: string \| null; } ; `type`: serviceNowITSM } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { category: string \| null; destIp: boolean \| null; malwareHash: boolean \| null; malwareUrl: boolean \| null; priority: string \| null; sourceIp: boolean \| null; subcategory: string \| null; } ; `type`: serviceNowSIR } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` ; `type`: none } + +Inherited from: CaseResponse.connector + +___ + +### created\_at + +โ€ข **created\_at**: *string* + +Inherited from: CaseResponse.created\_at + +___ + +### created\_by + +โ€ข **created\_by**: *object* + +#### Type declaration + +| Name | Type | +| :------ | :------ | +| `email` | *undefined* \| ``null`` \| *string* | +| `full_name` | *undefined* \| ``null`` \| *string* | +| `username` | *undefined* \| ``null`` \| *string* | + +Inherited from: CaseResponse.created\_by + +___ + +### description + +โ€ข **description**: *string* + +Inherited from: CaseResponse.description + +___ + +### external\_service + +โ€ข **external\_service**: ``null`` \| { `connector_id`: *string* ; `connector_name`: *string* ; `external_id`: *string* ; `external_title`: *string* ; `external_url`: *string* } & { `pushed_at`: *string* ; `pushed_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } + +Inherited from: CaseResponse.external\_service + +___ + +### id + +โ€ข **id**: *string* + +Inherited from: CaseResponse.id + +___ + +### owner + +โ€ข **owner**: *string* + +Inherited from: CaseResponse.owner + +___ + +### settings + +โ€ข **settings**: *object* + +#### Type declaration + +| Name | Type | +| :------ | :------ | +| `syncAlerts` | *boolean* | + +Inherited from: CaseResponse.settings + +___ + +### status + +โ€ข **status**: CaseStatuses + +Inherited from: CaseResponse.status + +___ + +### subCaseIds + +โ€ข **subCaseIds**: *undefined* \| *string*[] + +Inherited from: CaseResponse.subCaseIds + +___ + +### subCases + +โ€ข **subCases**: *undefined* \| { `status`: CaseStatuses } & { `closed_at`: ``null`` \| *string* ; `closed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `created_at`: *string* ; `created_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `totalAlerts`: *number* ; `totalComment`: *number* ; `version`: *string* } & { `comments`: *undefined* \| { `comment`: *string* ; `owner`: *string* ; `type`: user } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* }[] }[] + +Inherited from: CaseResponse.subCases + +___ + +### tags + +โ€ข **tags**: *string*[] + +Inherited from: CaseResponse.tags + +___ + +### title + +โ€ข **title**: *string* + +Inherited from: CaseResponse.title + +___ + +### totalAlerts + +โ€ข **totalAlerts**: *number* + +Inherited from: CaseResponse.totalAlerts + +___ + +### totalComment + +โ€ข **totalComment**: *number* + +Inherited from: CaseResponse.totalComment + +___ + +### type + +โ€ข **type**: CaseType + +Inherited from: CaseResponse.type + +___ + +### updated\_at + +โ€ข **updated\_at**: ``null`` \| *string* + +Inherited from: CaseResponse.updated\_at + +___ + +### updated\_by + +โ€ข **updated\_by**: ``null`` \| { `email`: *undefined* \| ``null`` \| *string* ; `full_name`: *undefined* \| ``null`` \| *string* ; `username`: *undefined* \| ``null`` \| *string* } + +Inherited from: CaseResponse.updated\_by + +___ + +### version + +โ€ข **version**: *string* + +Inherited from: CaseResponse.version diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigurepatch.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigurepatch.md new file mode 100644 index 0000000000000..3854fda03fb6a --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigurepatch.md @@ -0,0 +1,43 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICasesConfigurePatch + +# Interface: ICasesConfigurePatch + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICasesConfigurePatch + +## Hierarchy + +- *CasesConfigurePatch* + + โ†ณ **ICasesConfigurePatch** + +## Table of contents + +### Properties + +- [closure\_type](typedoc_interfaces.icasesconfigurepatch.md#closure_type) +- [connector](typedoc_interfaces.icasesconfigurepatch.md#connector) +- [version](typedoc_interfaces.icasesconfigurepatch.md#version) + +## Properties + +### closure\_type + +โ€ข **closure\_type**: *undefined* \| ``"close-by-user"`` \| ``"close-by-pushing"`` + +Inherited from: CasesConfigurePatch.closure\_type + +___ + +### connector + +โ€ข **connector**: *undefined* \| { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { issueType: string \| null; priority: string \| null; parent: string \| null; } ; `type`: jira } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { incidentTypes: string[] \| null; severityCode: string \| null; } ; `type`: resilient } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { impact: string \| null; severity: string \| null; urgency: string \| null; category: string \| null; subcategory: string \| null; } ; `type`: serviceNowITSM } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { category: string \| null; destIp: boolean \| null; malwareHash: boolean \| null; malwareUrl: boolean \| null; priority: string \| null; sourceIp: boolean \| null; subcategory: string \| null; } ; `type`: serviceNowSIR } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` ; `type`: none } + +Inherited from: CasesConfigurePatch.connector + +___ + +### version + +โ€ข **version**: *string* + +Inherited from: CasesConfigurePatch.version diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigurerequest.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigurerequest.md new file mode 100644 index 0000000000000..548e1a5c48f58 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigurerequest.md @@ -0,0 +1,43 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICasesConfigureRequest + +# Interface: ICasesConfigureRequest + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICasesConfigureRequest + +## Hierarchy + +- *CasesConfigureRequest* + + โ†ณ **ICasesConfigureRequest** + +## Table of contents + +### Properties + +- [closure\_type](typedoc_interfaces.icasesconfigurerequest.md#closure_type) +- [connector](typedoc_interfaces.icasesconfigurerequest.md#connector) +- [owner](typedoc_interfaces.icasesconfigurerequest.md#owner) + +## Properties + +### closure\_type + +โ€ข **closure\_type**: ``"close-by-user"`` \| ``"close-by-pushing"`` + +Inherited from: CasesConfigureRequest.closure\_type + +___ + +### connector + +โ€ข **connector**: { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { issueType: string \| null; priority: string \| null; parent: string \| null; } ; `type`: jira } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { incidentTypes: string[] \| null; severityCode: string \| null; } ; `type`: resilient } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { impact: string \| null; severity: string \| null; urgency: string \| null; category: string \| null; subcategory: string \| null; } ; `type`: serviceNowITSM } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { category: string \| null; destIp: boolean \| null; malwareHash: boolean \| null; malwareUrl: boolean \| null; priority: string \| null; sourceIp: boolean \| null; subcategory: string \| null; } ; `type`: serviceNowSIR } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` ; `type`: none } + +Inherited from: CasesConfigureRequest.connector + +___ + +### owner + +โ€ข **owner**: *string* + +Inherited from: CasesConfigureRequest.owner diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigureresponse.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigureresponse.md new file mode 100644 index 0000000000000..c493a4c6c0f0c --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesconfigureresponse.md @@ -0,0 +1,123 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICasesConfigureResponse + +# Interface: ICasesConfigureResponse + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICasesConfigureResponse + +## Hierarchy + +- *CasesConfigureResponse* + + โ†ณ **ICasesConfigureResponse** + +## Table of contents + +### Properties + +- [closure\_type](typedoc_interfaces.icasesconfigureresponse.md#closure_type) +- [connector](typedoc_interfaces.icasesconfigureresponse.md#connector) +- [created\_at](typedoc_interfaces.icasesconfigureresponse.md#created_at) +- [created\_by](typedoc_interfaces.icasesconfigureresponse.md#created_by) +- [error](typedoc_interfaces.icasesconfigureresponse.md#error) +- [id](typedoc_interfaces.icasesconfigureresponse.md#id) +- [mappings](typedoc_interfaces.icasesconfigureresponse.md#mappings) +- [owner](typedoc_interfaces.icasesconfigureresponse.md#owner) +- [updated\_at](typedoc_interfaces.icasesconfigureresponse.md#updated_at) +- [updated\_by](typedoc_interfaces.icasesconfigureresponse.md#updated_by) +- [version](typedoc_interfaces.icasesconfigureresponse.md#version) + +## Properties + +### closure\_type + +โ€ข **closure\_type**: ``"close-by-user"`` \| ``"close-by-pushing"`` + +Inherited from: CasesConfigureResponse.closure\_type + +___ + +### connector + +โ€ข **connector**: { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { issueType: string \| null; priority: string \| null; parent: string \| null; } ; `type`: jira } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { incidentTypes: string[] \| null; severityCode: string \| null; } ; `type`: resilient } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { impact: string \| null; severity: string \| null; urgency: string \| null; category: string \| null; subcategory: string \| null; } ; `type`: serviceNowITSM } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { category: string \| null; destIp: boolean \| null; malwareHash: boolean \| null; malwareUrl: boolean \| null; priority: string \| null; sourceIp: boolean \| null; subcategory: string \| null; } ; `type`: serviceNowSIR } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` ; `type`: none } + +Inherited from: CasesConfigureResponse.connector + +___ + +### created\_at + +โ€ข **created\_at**: *string* + +Inherited from: CasesConfigureResponse.created\_at + +___ + +### created\_by + +โ€ข **created\_by**: *object* + +#### Type declaration + +| Name | Type | +| :------ | :------ | +| `email` | *undefined* \| ``null`` \| *string* | +| `full_name` | *undefined* \| ``null`` \| *string* | +| `username` | *undefined* \| ``null`` \| *string* | + +Inherited from: CasesConfigureResponse.created\_by + +___ + +### error + +โ€ข **error**: ``null`` \| *string* + +Inherited from: CasesConfigureResponse.error + +___ + +### id + +โ€ข **id**: *string* + +Inherited from: CasesConfigureResponse.id + +___ + +### mappings + +โ€ข **mappings**: { `action_type`: ``"append"`` \| ``"nothing"`` \| ``"overwrite"`` ; `source`: ``"description"`` \| ``"title"`` \| ``"comments"`` ; `target`: *string* }[] + +Inherited from: CasesConfigureResponse.mappings + +___ + +### owner + +โ€ข **owner**: *string* + +Inherited from: CasesConfigureResponse.owner + +___ + +### updated\_at + +โ€ข **updated\_at**: ``null`` \| *string* + +Inherited from: CasesConfigureResponse.updated\_at + +___ + +### updated\_by + +โ€ข **updated\_by**: ``null`` \| { `email`: *undefined* \| ``null`` \| *string* ; `full_name`: *undefined* \| ``null`` \| *string* ; `username`: *undefined* \| ``null`` \| *string* } + +Inherited from: CasesConfigureResponse.updated\_by + +___ + +### version + +โ€ข **version**: *string* + +Inherited from: CasesConfigureResponse.version diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesfindrequest.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesfindrequest.md new file mode 100644 index 0000000000000..cb8ec7797677f --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesfindrequest.md @@ -0,0 +1,133 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICasesFindRequest + +# Interface: ICasesFindRequest + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICasesFindRequest + +## Hierarchy + +- *CasesFindRequest* + + โ†ณ **ICasesFindRequest** + +## Table of contents + +### Properties + +- [defaultSearchOperator](typedoc_interfaces.icasesfindrequest.md#defaultsearchoperator) +- [fields](typedoc_interfaces.icasesfindrequest.md#fields) +- [owner](typedoc_interfaces.icasesfindrequest.md#owner) +- [page](typedoc_interfaces.icasesfindrequest.md#page) +- [perPage](typedoc_interfaces.icasesfindrequest.md#perpage) +- [reporters](typedoc_interfaces.icasesfindrequest.md#reporters) +- [search](typedoc_interfaces.icasesfindrequest.md#search) +- [searchFields](typedoc_interfaces.icasesfindrequest.md#searchfields) +- [sortField](typedoc_interfaces.icasesfindrequest.md#sortfield) +- [sortOrder](typedoc_interfaces.icasesfindrequest.md#sortorder) +- [status](typedoc_interfaces.icasesfindrequest.md#status) +- [tags](typedoc_interfaces.icasesfindrequest.md#tags) +- [type](typedoc_interfaces.icasesfindrequest.md#type) + +## Properties + +### defaultSearchOperator + +โ€ข **defaultSearchOperator**: *undefined* \| ``"AND"`` \| ``"OR"`` + +Inherited from: CasesFindRequest.defaultSearchOperator + +___ + +### fields + +โ€ข **fields**: *undefined* \| *string*[] + +Inherited from: CasesFindRequest.fields + +___ + +### owner + +โ€ข **owner**: *undefined* \| *string* \| *string*[] + +Inherited from: CasesFindRequest.owner + +___ + +### page + +โ€ข **page**: *undefined* \| *number* + +Inherited from: CasesFindRequest.page + +___ + +### perPage + +โ€ข **perPage**: *undefined* \| *number* + +Inherited from: CasesFindRequest.perPage + +___ + +### reporters + +โ€ข **reporters**: *undefined* \| *string* \| *string*[] + +Inherited from: CasesFindRequest.reporters + +___ + +### search + +โ€ข **search**: *undefined* \| *string* + +Inherited from: CasesFindRequest.search + +___ + +### searchFields + +โ€ข **searchFields**: *undefined* \| *string* \| *string*[] + +Inherited from: CasesFindRequest.searchFields + +___ + +### sortField + +โ€ข **sortField**: *undefined* \| *string* + +Inherited from: CasesFindRequest.sortField + +___ + +### sortOrder + +โ€ข **sortOrder**: *undefined* \| ``"desc"`` \| ``"asc"`` + +Inherited from: CasesFindRequest.sortOrder + +___ + +### status + +โ€ข **status**: *undefined* \| open \| *any*[*any*] \| closed + +Inherited from: CasesFindRequest.status + +___ + +### tags + +โ€ข **tags**: *undefined* \| *string* \| *string*[] + +Inherited from: CasesFindRequest.tags + +___ + +### type + +โ€ข **type**: *undefined* \| collection \| individual + +Inherited from: CasesFindRequest.type diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesfindresponse.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesfindresponse.md new file mode 100644 index 0000000000000..9be5fd5743a8e --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesfindresponse.md @@ -0,0 +1,79 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICasesFindResponse + +# Interface: ICasesFindResponse + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICasesFindResponse + +## Hierarchy + +- *CasesFindResponse* + + โ†ณ **ICasesFindResponse** + +## Table of contents + +### Properties + +- [cases](typedoc_interfaces.icasesfindresponse.md#cases) +- [count\_closed\_cases](typedoc_interfaces.icasesfindresponse.md#count_closed_cases) +- [count\_in\_progress\_cases](typedoc_interfaces.icasesfindresponse.md#count_in_progress_cases) +- [count\_open\_cases](typedoc_interfaces.icasesfindresponse.md#count_open_cases) +- [page](typedoc_interfaces.icasesfindresponse.md#page) +- [per\_page](typedoc_interfaces.icasesfindresponse.md#per_page) +- [total](typedoc_interfaces.icasesfindresponse.md#total) + +## Properties + +### cases + +โ€ข **cases**: { `connector`: { id: string; name: string; } & { type: ConnectorTypes.jira; fields: { issueType: string \| null; priority: string \| null; parent: string \| null; } \| null; } & { id: string; name: string; } & { type: ConnectorTypes.resilient; fields: { incidentTypes: string[] \| null; severityCode: string \| null; } \| null; } & { id: string; name: string; } & { type: ConnectorTypes.serviceNowITSM; fields: { impact: string \| null; severity: string \| null; urgency: string \| null; category: string \| null; subcategory: string \| null; } \| null; } & { id: string; name: string; } & { type: ConnectorTypes.serviceNowSIR; fields: { category: string \| null; destIp: boolean \| null; malwareHash: boolean \| null; malwareUrl: boolean \| null; priority: string \| null; sourceIp: boolean \| null; subcategory: string \| null; } \| null; } & { id: string; name: string; } & { type: ConnectorTypes.none; fields: null; } ; `description`: *string* ; `owner`: *string* ; `settings`: { syncAlerts: boolean; } ; `status`: CaseStatuses ; `tags`: *string*[] ; `title`: *string* ; `type`: CaseType } & { `closed_at`: ``null`` \| *string* ; `closed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `external_service`: ``null`` \| { connector\_id: string; connector\_name: string; external\_id: string; external\_title: string; external\_url: string; } & { pushed\_at: string; pushed\_by: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; }; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `totalAlerts`: *number* ; `totalComment`: *number* ; `version`: *string* } & { `comments`: *undefined* \| { `comment`: *string* ; `owner`: *string* ; `type`: user } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* }[] ; `subCaseIds`: *undefined* \| *string*[] ; `subCases`: *undefined* \| { `status`: CaseStatuses } & { `closed_at`: ``null`` \| *string* ; `closed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `created_at`: *string* ; `created_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `totalAlerts`: *number* ; `totalComment`: *number* ; `version`: *string* } & { comments?: ((({ comment: string; type: CommentType.user; owner: string; } & { associationType: AssociationType; created\_at: string; created\_by: { email: string \| null \| undefined; full\_name: string \| ... 1 more ... \| undefined; username: string \| ... 1 more ... \| undefined; }; ... 4 more ...; updated\_by: { ...; } ...[] }[] + +Inherited from: CasesFindResponse.cases + +___ + +### count\_closed\_cases + +โ€ข **count\_closed\_cases**: *number* + +Inherited from: CasesFindResponse.count\_closed\_cases + +___ + +### count\_in\_progress\_cases + +โ€ข **count\_in\_progress\_cases**: *number* + +Inherited from: CasesFindResponse.count\_in\_progress\_cases + +___ + +### count\_open\_cases + +โ€ข **count\_open\_cases**: *number* + +Inherited from: CasesFindResponse.count\_open\_cases + +___ + +### page + +โ€ข **page**: *number* + +Inherited from: CasesFindResponse.page + +___ + +### per\_page + +โ€ข **per\_page**: *number* + +Inherited from: CasesFindResponse.per\_page + +___ + +### total + +โ€ข **total**: *number* + +Inherited from: CasesFindResponse.total diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasespatchrequest.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasespatchrequest.md new file mode 100644 index 0000000000000..bfdb3b7315e55 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasespatchrequest.md @@ -0,0 +1,25 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICasesPatchRequest + +# Interface: ICasesPatchRequest + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICasesPatchRequest + +## Hierarchy + +- *CasesPatchRequest* + + โ†ณ **ICasesPatchRequest** + +## Table of contents + +### Properties + +- [cases](typedoc_interfaces.icasespatchrequest.md#cases) + +## Properties + +### cases + +โ€ข **cases**: { `connector`: *undefined* \| { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { issueType: string \| null; priority: string \| null; parent: string \| null; } ; `type`: jira } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { incidentTypes: string[] \| null; severityCode: string \| null; } ; `type`: resilient } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { impact: string \| null; severity: string \| null; urgency: string \| null; category: string \| null; subcategory: string \| null; } ; `type`: serviceNowITSM } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` \| { category: string \| null; destIp: boolean \| null; malwareHash: boolean \| null; malwareUrl: boolean \| null; priority: string \| null; sourceIp: boolean \| null; subcategory: string \| null; } ; `type`: serviceNowSIR } & { `id`: *string* ; `name`: *string* } & { `fields`: ``null`` ; `type`: none } ; `description`: *undefined* \| *string* ; `owner`: *undefined* \| *string* ; `settings`: *undefined* \| { `syncAlerts`: *boolean* } ; `status`: *undefined* \| open \| *any*[*any*] \| closed ; `tags`: *undefined* \| *string*[] ; `title`: *undefined* \| *string* ; `type`: *undefined* \| collection \| individual } & { `id`: *string* ; `version`: *string* }[] + +Inherited from: CasesPatchRequest.cases diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesresponse.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesresponse.md new file mode 100644 index 0000000000000..2c9eed242d1fb --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icasesresponse.md @@ -0,0 +1,11 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICasesResponse + +# Interface: ICasesResponse + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICasesResponse + +## Hierarchy + +- *CasesResponse* + + โ†ณ **ICasesResponse** diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icaseuseractionsresponse.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icaseuseractionsresponse.md new file mode 100644 index 0000000000000..0347711e331dc --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icaseuseractionsresponse.md @@ -0,0 +1,11 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICaseUserActionsResponse + +# Interface: ICaseUserActionsResponse + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICaseUserActionsResponse + +## Hierarchy + +- *CaseUserActionsResponse* + + โ†ณ **ICaseUserActionsResponse** diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icommentsresponse.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icommentsresponse.md new file mode 100644 index 0000000000000..d34480b2c633c --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.icommentsresponse.md @@ -0,0 +1,52 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ICommentsResponse + +# Interface: ICommentsResponse + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ICommentsResponse + +## Hierarchy + +- *CommentsResponse* + + โ†ณ **ICommentsResponse** + +## Table of contents + +### Properties + +- [comments](typedoc_interfaces.icommentsresponse.md#comments) +- [page](typedoc_interfaces.icommentsresponse.md#page) +- [per\_page](typedoc_interfaces.icommentsresponse.md#per_page) +- [total](typedoc_interfaces.icommentsresponse.md#total) + +## Properties + +### comments + +โ€ข **comments**: { `comment`: *string* ; `owner`: *string* ; `type`: user } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* }[] + +Inherited from: CommentsResponse.comments + +___ + +### page + +โ€ข **page**: *number* + +Inherited from: CommentsResponse.page + +___ + +### per\_page + +โ€ข **per\_page**: *number* + +Inherited from: CommentsResponse.per\_page + +___ + +### total + +โ€ข **total**: *number* + +Inherited from: CommentsResponse.total diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcaseresponse.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcaseresponse.md new file mode 100644 index 0000000000000..b33b280d2e753 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcaseresponse.md @@ -0,0 +1,133 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ISubCaseResponse + +# Interface: ISubCaseResponse + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ISubCaseResponse + +## Hierarchy + +- *SubCaseResponse* + + โ†ณ **ISubCaseResponse** + +## Table of contents + +### Properties + +- [closed\_at](typedoc_interfaces.isubcaseresponse.md#closed_at) +- [closed\_by](typedoc_interfaces.isubcaseresponse.md#closed_by) +- [comments](typedoc_interfaces.isubcaseresponse.md#comments) +- [created\_at](typedoc_interfaces.isubcaseresponse.md#created_at) +- [created\_by](typedoc_interfaces.isubcaseresponse.md#created_by) +- [id](typedoc_interfaces.isubcaseresponse.md#id) +- [owner](typedoc_interfaces.isubcaseresponse.md#owner) +- [status](typedoc_interfaces.isubcaseresponse.md#status) +- [totalAlerts](typedoc_interfaces.isubcaseresponse.md#totalalerts) +- [totalComment](typedoc_interfaces.isubcaseresponse.md#totalcomment) +- [updated\_at](typedoc_interfaces.isubcaseresponse.md#updated_at) +- [updated\_by](typedoc_interfaces.isubcaseresponse.md#updated_by) +- [version](typedoc_interfaces.isubcaseresponse.md#version) + +## Properties + +### closed\_at + +โ€ข **closed\_at**: ``null`` \| *string* + +Inherited from: SubCaseResponse.closed\_at + +___ + +### closed\_by + +โ€ข **closed\_by**: ``null`` \| { `email`: *undefined* \| ``null`` \| *string* ; `full_name`: *undefined* \| ``null`` \| *string* ; `username`: *undefined* \| ``null`` \| *string* } + +Inherited from: SubCaseResponse.closed\_by + +___ + +### comments + +โ€ข **comments**: *undefined* \| { `comment`: *string* ; `owner`: *string* ; `type`: user } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* }[] + +Inherited from: SubCaseResponse.comments + +___ + +### created\_at + +โ€ข **created\_at**: *string* + +Inherited from: SubCaseResponse.created\_at + +___ + +### created\_by + +โ€ข **created\_by**: ``null`` \| { `email`: *undefined* \| ``null`` \| *string* ; `full_name`: *undefined* \| ``null`` \| *string* ; `username`: *undefined* \| ``null`` \| *string* } + +Inherited from: SubCaseResponse.created\_by + +___ + +### id + +โ€ข **id**: *string* + +Inherited from: SubCaseResponse.id + +___ + +### owner + +โ€ข **owner**: *string* + +Inherited from: SubCaseResponse.owner + +___ + +### status + +โ€ข **status**: CaseStatuses + +Inherited from: SubCaseResponse.status + +___ + +### totalAlerts + +โ€ข **totalAlerts**: *number* + +Inherited from: SubCaseResponse.totalAlerts + +___ + +### totalComment + +โ€ข **totalComment**: *number* + +Inherited from: SubCaseResponse.totalComment + +___ + +### updated\_at + +โ€ข **updated\_at**: ``null`` \| *string* + +Inherited from: SubCaseResponse.updated\_at + +___ + +### updated\_by + +โ€ข **updated\_by**: ``null`` \| { `email`: *undefined* \| ``null`` \| *string* ; `full_name`: *undefined* \| ``null`` \| *string* ; `username`: *undefined* \| ``null`` \| *string* } + +Inherited from: SubCaseResponse.updated\_by + +___ + +### version + +โ€ข **version**: *string* + +Inherited from: SubCaseResponse.version diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcasesfindresponse.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcasesfindresponse.md new file mode 100644 index 0000000000000..35d63126f608a --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcasesfindresponse.md @@ -0,0 +1,79 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ISubCasesFindResponse + +# Interface: ISubCasesFindResponse + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ISubCasesFindResponse + +## Hierarchy + +- *SubCasesFindResponse* + + โ†ณ **ISubCasesFindResponse** + +## Table of contents + +### Properties + +- [count\_closed\_cases](typedoc_interfaces.isubcasesfindresponse.md#count_closed_cases) +- [count\_in\_progress\_cases](typedoc_interfaces.isubcasesfindresponse.md#count_in_progress_cases) +- [count\_open\_cases](typedoc_interfaces.isubcasesfindresponse.md#count_open_cases) +- [page](typedoc_interfaces.isubcasesfindresponse.md#page) +- [per\_page](typedoc_interfaces.isubcasesfindresponse.md#per_page) +- [subCases](typedoc_interfaces.isubcasesfindresponse.md#subcases) +- [total](typedoc_interfaces.isubcasesfindresponse.md#total) + +## Properties + +### count\_closed\_cases + +โ€ข **count\_closed\_cases**: *number* + +Inherited from: SubCasesFindResponse.count\_closed\_cases + +___ + +### count\_in\_progress\_cases + +โ€ข **count\_in\_progress\_cases**: *number* + +Inherited from: SubCasesFindResponse.count\_in\_progress\_cases + +___ + +### count\_open\_cases + +โ€ข **count\_open\_cases**: *number* + +Inherited from: SubCasesFindResponse.count\_open\_cases + +___ + +### page + +โ€ข **page**: *number* + +Inherited from: SubCasesFindResponse.page + +___ + +### per\_page + +โ€ข **per\_page**: *number* + +Inherited from: SubCasesFindResponse.per\_page + +___ + +### subCases + +โ€ข **subCases**: { `status`: CaseStatuses } & { `closed_at`: ``null`` \| *string* ; `closed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `created_at`: *string* ; `created_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `totalAlerts`: *number* ; `totalComment`: *number* ; `version`: *string* } & { `comments`: *undefined* \| { `comment`: *string* ; `owner`: *string* ; `type`: user } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* } & { `alertId`: *string* \| *string*[] ; `index`: *string* \| *string*[] ; `owner`: *string* ; `rule`: { id: string \| null; name: string \| null; } ; `type`: alert \| generatedAlert } & { `associationType`: AssociationType ; `created_at`: *string* ; `created_by`: { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `owner`: *string* ; `pushed_at`: ``null`` \| *string* ; `pushed_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } ; `updated_at`: ``null`` \| *string* ; `updated_by`: ``null`` \| { email: string \| null \| undefined; full\_name: string \| null \| undefined; username: string \| null \| undefined; } } & { `id`: *string* ; `version`: *string* }[] }[] + +Inherited from: SubCasesFindResponse.subCases + +___ + +### total + +โ€ข **total**: *number* + +Inherited from: SubCasesFindResponse.total diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcasesresponse.md b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcasesresponse.md new file mode 100644 index 0000000000000..6ee45e59b53b5 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/typedoc_interfaces.isubcasesresponse.md @@ -0,0 +1,11 @@ +[Cases Client API Interface](../cases_client_api.md) / [typedoc_interfaces](../modules/typedoc_interfaces.md) / ISubCasesResponse + +# Interface: ISubCasesResponse + +[typedoc_interfaces](../modules/typedoc_interfaces.md).ISubCasesResponse + +## Hierarchy + +- *SubCasesResponse* + + โ†ณ **ISubCasesResponse** diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md new file mode 100644 index 0000000000000..2c0c084ab9b30 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionget.md @@ -0,0 +1,34 @@ +[Cases Client API Interface](../cases_client_api.md) / [user_actions/client](../modules/user_actions_client.md) / UserActionGet + +# Interface: UserActionGet + +[user_actions/client](../modules/user_actions_client.md).UserActionGet + +Parameters for retrieving user actions for a particular case + +## Table of contents + +### Properties + +- [caseId](user_actions_client.useractionget.md#caseid) +- [subCaseId](user_actions_client.useractionget.md#subcaseid) + +## Properties + +### caseId + +โ€ข **caseId**: *string* + +The ID of the case + +Defined in: [user_actions/client.ts:19](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/user_actions/client.ts#L19) + +___ + +### subCaseId + +โ€ข `Optional` **subCaseId**: *string* + +If specified then a sub case will be used for finding all the user actions + +Defined in: [user_actions/client.ts:23](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/user_actions/client.ts#L23) diff --git a/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md new file mode 100644 index 0000000000000..f03667eccb858 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/interfaces/user_actions_client.useractionssubclient.md @@ -0,0 +1,31 @@ +[Cases Client API Interface](../cases_client_api.md) / [user_actions/client](../modules/user_actions_client.md) / UserActionsSubClient + +# Interface: UserActionsSubClient + +[user_actions/client](../modules/user_actions_client.md).UserActionsSubClient + +API for interacting the actions performed by a user when interacting with the cases entities. + +## Table of contents + +### Methods + +- [getAll](user_actions_client.useractionssubclient.md#getall) + +## Methods + +### getAll + +โ–ธ **getAll**(`clientArgs`: [*UserActionGet*](user_actions_client.useractionget.md)): *Promise*<[*ICaseUserActionsResponse*](typedoc_interfaces.icaseuseractionsresponse.md)\> + +Retrieves all user actions for a particular case. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `clientArgs` | [*UserActionGet*](user_actions_client.useractionget.md) | + +**Returns:** *Promise*<[*ICaseUserActionsResponse*](typedoc_interfaces.icaseuseractionsresponse.md)\> + +Defined in: [user_actions/client.ts:33](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/user_actions/client.ts#L33) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/attachments_add.md b/x-pack/plugins/cases/docs/cases_client/modules/attachments_add.md new file mode 100644 index 0000000000000..d9ac6e6ce431b --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/attachments_add.md @@ -0,0 +1,9 @@ +[Cases Client API Interface](../cases_client_api.md) / attachments/add + +# Module: attachments/add + +## Table of contents + +### Interfaces + +- [AddArgs](../interfaces/attachments_add.addargs.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/attachments_client.md b/x-pack/plugins/cases/docs/cases_client/modules/attachments_client.md new file mode 100644 index 0000000000000..47d96b98356e7 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/attachments_client.md @@ -0,0 +1,9 @@ +[Cases Client API Interface](../cases_client_api.md) / attachments/client + +# Module: attachments/client + +## Table of contents + +### Interfaces + +- [AttachmentsSubClient](../interfaces/attachments_client.attachmentssubclient.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/attachments_delete.md b/x-pack/plugins/cases/docs/cases_client/modules/attachments_delete.md new file mode 100644 index 0000000000000..0e2cf420b6375 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/attachments_delete.md @@ -0,0 +1,10 @@ +[Cases Client API Interface](../cases_client_api.md) / attachments/delete + +# Module: attachments/delete + +## Table of contents + +### Interfaces + +- [DeleteAllArgs](../interfaces/attachments_delete.deleteallargs.md) +- [DeleteArgs](../interfaces/attachments_delete.deleteargs.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/attachments_get.md b/x-pack/plugins/cases/docs/cases_client/modules/attachments_get.md new file mode 100644 index 0000000000000..99358d6683256 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/attachments_get.md @@ -0,0 +1,11 @@ +[Cases Client API Interface](../cases_client_api.md) / attachments/get + +# Module: attachments/get + +## Table of contents + +### Interfaces + +- [FindArgs](../interfaces/attachments_get.findargs.md) +- [GetAllArgs](../interfaces/attachments_get.getallargs.md) +- [GetArgs](../interfaces/attachments_get.getargs.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/attachments_update.md b/x-pack/plugins/cases/docs/cases_client/modules/attachments_update.md new file mode 100644 index 0000000000000..011fe531ede34 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/attachments_update.md @@ -0,0 +1,9 @@ +[Cases Client API Interface](../cases_client_api.md) / attachments/update + +# Module: attachments/update + +## Table of contents + +### Interfaces + +- [UpdateArgs](../interfaces/attachments_update.updateargs.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/cases_client.md b/x-pack/plugins/cases/docs/cases_client/modules/cases_client.md new file mode 100644 index 0000000000000..c6e9cf17d9840 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/cases_client.md @@ -0,0 +1,9 @@ +[Cases Client API Interface](../cases_client_api.md) / cases/client + +# Module: cases/client + +## Table of contents + +### Interfaces + +- [CasesSubClient](../interfaces/cases_client.casessubclient.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md b/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md new file mode 100644 index 0000000000000..9e896881df17b --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/cases_get.md @@ -0,0 +1,53 @@ +[Cases Client API Interface](../cases_client_api.md) / cases/get + +# Module: cases/get + +## Table of contents + +### Interfaces + +- [CaseIDsByAlertIDParams](../interfaces/cases_get.caseidsbyalertidparams.md) +- [GetParams](../interfaces/cases_get.getparams.md) + +### Functions + +- [getReporters](cases_get.md#getreporters) +- [getTags](cases_get.md#gettags) + +## Functions + +### getReporters + +โ–ธ **getReporters**(`params`: AllReportersFindRequest, `clientArgs`: CasesClientArgs): *Promise* + +Retrieves the reporters from all the cases. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params` | AllReportersFindRequest | +| `clientArgs` | CasesClientArgs | + +**Returns:** *Promise* + +Defined in: [cases/get.ts:279](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/get.ts#L279) + +___ + +### getTags + +โ–ธ **getTags**(`params`: AllTagsFindRequest, `clientArgs`: CasesClientArgs): *Promise* + +Retrieves the tags from all the cases. + +#### Parameters + +| Name | Type | +| :------ | :------ | +| `params` | AllTagsFindRequest | +| `clientArgs` | CasesClientArgs | + +**Returns:** *Promise* + +Defined in: [cases/get.ts:217](https://github.com/jonathan-buttner/kibana/blob/2085a3b4480/x-pack/plugins/cases/server/client/cases/get.ts#L217) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/cases_push.md b/x-pack/plugins/cases/docs/cases_client/modules/cases_push.md new file mode 100644 index 0000000000000..4be9df64bb420 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/cases_push.md @@ -0,0 +1,9 @@ +[Cases Client API Interface](../cases_client_api.md) / cases/push + +# Module: cases/push + +## Table of contents + +### Interfaces + +- [PushParams](../interfaces/cases_push.pushparams.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/client.md b/x-pack/plugins/cases/docs/cases_client/modules/client.md new file mode 100644 index 0000000000000..7fb6b64253dd9 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/client.md @@ -0,0 +1,9 @@ +[Cases Client API Interface](../cases_client_api.md) / client + +# Module: client + +## Table of contents + +### Classes + +- [CasesClient](../classes/client.casesclient.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/configure_client.md b/x-pack/plugins/cases/docs/cases_client/modules/configure_client.md new file mode 100644 index 0000000000000..7cfc43e3d0a88 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/configure_client.md @@ -0,0 +1,9 @@ +[Cases Client API Interface](../cases_client_api.md) / configure/client + +# Module: configure/client + +## Table of contents + +### Interfaces + +- [ConfigureSubClient](../interfaces/configure_client.configuresubclient.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/stats_client.md b/x-pack/plugins/cases/docs/cases_client/modules/stats_client.md new file mode 100644 index 0000000000000..992a1a1ab501a --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/stats_client.md @@ -0,0 +1,9 @@ +[Cases Client API Interface](../cases_client_api.md) / stats/client + +# Module: stats/client + +## Table of contents + +### Interfaces + +- [StatsSubClient](../interfaces/stats_client.statssubclient.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/sub_cases_client.md b/x-pack/plugins/cases/docs/cases_client/modules/sub_cases_client.md new file mode 100644 index 0000000000000..6bdf073566b1c --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/sub_cases_client.md @@ -0,0 +1,9 @@ +[Cases Client API Interface](../cases_client_api.md) / sub_cases/client + +# Module: sub\_cases/client + +## Table of contents + +### Interfaces + +- [SubCasesClient](../interfaces/sub_cases_client.subcasesclient.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/typedoc_interfaces.md b/x-pack/plugins/cases/docs/cases_client/modules/typedoc_interfaces.md new file mode 100644 index 0000000000000..4719d2a2719c0 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/typedoc_interfaces.md @@ -0,0 +1,26 @@ +[Cases Client API Interface](../cases_client_api.md) / typedoc_interfaces + +# Module: typedoc\_interfaces + +This file defines simpler types for typedoc. This helps reduce the type alias expansion for the io-ts types because it +can be very large. These types are equivalent to the io-ts aliases. + +## Table of contents + +### Interfaces + +- [IAllCommentsResponse](../interfaces/typedoc_interfaces.iallcommentsresponse.md) +- [ICasePostRequest](../interfaces/typedoc_interfaces.icasepostrequest.md) +- [ICaseResponse](../interfaces/typedoc_interfaces.icaseresponse.md) +- [ICaseUserActionsResponse](../interfaces/typedoc_interfaces.icaseuseractionsresponse.md) +- [ICasesConfigurePatch](../interfaces/typedoc_interfaces.icasesconfigurepatch.md) +- [ICasesConfigureRequest](../interfaces/typedoc_interfaces.icasesconfigurerequest.md) +- [ICasesConfigureResponse](../interfaces/typedoc_interfaces.icasesconfigureresponse.md) +- [ICasesFindRequest](../interfaces/typedoc_interfaces.icasesfindrequest.md) +- [ICasesFindResponse](../interfaces/typedoc_interfaces.icasesfindresponse.md) +- [ICasesPatchRequest](../interfaces/typedoc_interfaces.icasespatchrequest.md) +- [ICasesResponse](../interfaces/typedoc_interfaces.icasesresponse.md) +- [ICommentsResponse](../interfaces/typedoc_interfaces.icommentsresponse.md) +- [ISubCaseResponse](../interfaces/typedoc_interfaces.isubcaseresponse.md) +- [ISubCasesFindResponse](../interfaces/typedoc_interfaces.isubcasesfindresponse.md) +- [ISubCasesResponse](../interfaces/typedoc_interfaces.isubcasesresponse.md) diff --git a/x-pack/plugins/cases/docs/cases_client/modules/user_actions_client.md b/x-pack/plugins/cases/docs/cases_client/modules/user_actions_client.md new file mode 100644 index 0000000000000..b48e3faac2135 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client/modules/user_actions_client.md @@ -0,0 +1,10 @@ +[Cases Client API Interface](../cases_client_api.md) / user_actions/client + +# Module: user\_actions/client + +## Table of contents + +### Interfaces + +- [UserActionGet](../interfaces/user_actions_client.useractionget.md) +- [UserActionsSubClient](../interfaces/user_actions_client.useractionssubclient.md) diff --git a/x-pack/plugins/cases/docs/cases_client_typedoc.json b/x-pack/plugins/cases/docs/cases_client_typedoc.json new file mode 100644 index 0000000000000..5f67719b47574 --- /dev/null +++ b/x-pack/plugins/cases/docs/cases_client_typedoc.json @@ -0,0 +1,25 @@ +{ + "entryPoints": [ + "../server/client/client.ts", + "../server/client/typedoc_interfaces.ts", + "../server/client/attachments", + "../server/client/cases/client.ts", + "../server/client/cases/get.ts", + "../server/client/cases/push.ts", + "../server/client/configure/client.ts", + "../server/client/stats/client.ts", + "../server/client/sub_cases/client.ts", + "../server/client/user_actions/client.ts" + ], + "exclude": [ + "**/mock.ts", + "../server/client/cases/+(mock.ts|utils.ts|utils.test.ts|types.ts)" + ], + "excludeExternals": true, + "out": "cases_client", + "theme": "markdown", + "plugin": "typedoc-plugin-markdown", + "entryDocument": "cases_client_api.md", + "readme": "none", + "name": "Cases Client API Interface" +} diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index 4a534c29de804..c59800aaf9bcb 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -3,7 +3,7 @@ "id": "cases", "kibanaVersion": "kibana", "extraPublicDirs": ["common"], - "requiredPlugins": ["actions", "esUiShared", "kibanaReact", "kibanaUtils", "triggersActionsUi"], + "requiredPlugins": ["actions", "esUiShared", "features", "kibanaReact", "kibanaUtils", "triggersActionsUi"], "optionalPlugins": [ "spaces", "security" diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts index cb90568982282..5246e09f6b0f3 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -104,29 +104,3 @@ export const useCurrentUser = (): AuthenticatedElasticUser | null => { }, [fetchUser]); return user; }; - -export interface UseGetUserSavedObjectPermissions { - crud: boolean; - read: boolean; -} - -export const useGetUserSavedObjectPermissions = () => { - const [ - savedObjectsPermissions, - setSavedObjectsPermissions, - ] = useState(null); - const uiCapabilities = useKibana().services.application.capabilities; - - useEffect(() => { - const capabilitiesCanUserCRUD: boolean = - typeof uiCapabilities.siem.crud === 'boolean' ? uiCapabilities.siem.crud : false; - const capabilitiesCanUserRead: boolean = - typeof uiCapabilities.siem.show === 'boolean' ? uiCapabilities.siem.show : false; - setSavedObjectsPermissions({ - crud: capabilitiesCanUserCRUD, - read: capabilitiesCanUserRead, - }); - }, [uiCapabilities]); - - return savedObjectsPermissions; -}; diff --git a/x-pack/plugins/cases/public/common/mock/test_providers.tsx b/x-pack/plugins/cases/public/common/mock/test_providers.tsx index 94ee5dd4f2743..9a08918a483a5 100644 --- a/x-pack/plugins/cases/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/cases/public/common/mock/test_providers.tsx @@ -10,6 +10,8 @@ import { I18nProvider } from '@kbn/i18n/react'; import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { ThemeProvider } from 'styled-components'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; +import { OwnerProvider } from '../../components/owner_context'; import { createKibanaContextProviderMock, createStartServicesMock, @@ -29,7 +31,9 @@ const MockKibanaContextProvider = createKibanaContextProviderMock(); const TestProvidersComponent: React.FC = ({ children }) => ( - ({ eui: euiDarkVars, darkMode: true })}>{children} + ({ eui: euiDarkVars, darkMode: true })}> + {children} + ); diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 834bd1292ccdd..85cfb60b1d6b8 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -7,18 +7,18 @@ import { i18n } from '@kbn/i18n'; -export const SAVED_OBJECT_NO_PERMISSIONS_TITLE = i18n.translate( - 'xpack.cases.caseSavedObjectNoPermissionsTitle', +export const CASES_FEATURE_NO_PERMISSIONS_TITLE = i18n.translate( + 'xpack.cases.caseFeatureNoPermissionsTitle', { defaultMessage: 'Kibana feature privileges required', } ); -export const SAVED_OBJECT_NO_PERMISSIONS_MSG = i18n.translate( - 'xpack.cases.caseSavedObjectNoPermissionsMessage', +export const CASES_FEATURE_NO_PERMISSIONS_MSG = i18n.translate( + 'xpack.cases.caseFeatureNoPermissionsMessage', { defaultMessage: - 'To view cases, you must have privileges for the Saved Object Management feature in the Kibana space. For more information, contact your Kibana administrator.', + 'To view cases, you must have privileges for the Cases feature in the Kibana space. For more information, contact your Kibana administrator.', } ); diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index d35a3dc6a7462..23a0fca48592f 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -13,7 +13,7 @@ import { noop } from 'lodash/fp'; import { TestProviders } from '../../common/mock'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; -import { CommentRequest, CommentType } from '../../../common'; +import { CommentRequest, CommentType, SECURITY_SOLUTION_OWNER } from '../../../common'; import { usePostComment } from '../../containers/use_post_comment'; import { AddComment, AddCommentRefObject } from '.'; import { CasesTimelineIntegrationProvider } from '../timeline_context'; @@ -44,6 +44,7 @@ const defaultPostComment = { const sampleData: CommentRequest = { comment: 'what a cool comment', type: CommentType.user, + owner: SECURITY_SOLUTION_OWNER, }; describe('AddComment ', () => { diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx index b4aadc85ad5a7..04104f0b9471d 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -18,6 +18,7 @@ import { Form, useForm, UseField, useFormData } from '../../common/shared_import import * as i18n from './translations'; import { schema, AddCommentFormSchema } from './schema'; import { InsertTimeline } from '../insert_timeline'; +import { useOwnerContext } from '../owner_context/use_owner_context'; const MySpinner = styled(EuiLoadingSpinner)` position: absolute; top: 50%; @@ -47,6 +48,7 @@ export const AddComment = React.memo( { caseId, disabled, onCommentPosted, onCommentSaving, showLoading = true, subCaseId }, ref ) => { + const owner = useOwnerContext(); const { isLoading, postComment } = usePostComment(); const { form } = useForm({ @@ -78,13 +80,13 @@ export const AddComment = React.memo( } postComment({ caseId, - data: { ...data, type: CommentType.user }, + data: { ...data, type: CommentType.user, owner: owner[0] }, updateCase: onCommentPosted, subCaseId, }); reset(); } - }, [caseId, onCommentPosted, onCommentSaving, postComment, reset, submit, subCaseId]); + }, [submit, onCommentSaving, postComment, caseId, owner, onCommentPosted, subCaseId, reset]); return ( diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx index 0e8d1da74b606..a0a5bb08ef770 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx @@ -14,7 +14,7 @@ import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; import { useGetActionLicense } from '../../containers/use_get_action_license'; import { StatusAll } from '../../containers/types'; -import { CaseStatuses } from '../../../common'; +import { CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../../common'; import { act } from 'react-dom/test-utils'; jest.mock('../../containers/use_get_reporters'); @@ -32,6 +32,7 @@ const alertDataMock = { }, index: 'index-id', alertId: 'alert-id', + owner: SECURITY_SOLUTION_OWNER, }; describe('AllCasesGeneric ', () => { diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx index 36527bd96700b..41509d9c0d135 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx @@ -40,6 +40,7 @@ import { CasesTableFilters } from './table_filters'; import { EuiBasicTableOnChange } from './types'; import { CasesTable } from './table'; + const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => $isShow @@ -81,6 +82,7 @@ export const AllCasesGeneric = React.memo( userCanCrud, }) => { const { actionLicense } = useGetActionLicense(); + const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses)); const initialFilterOptions = !isEmpty(hiddenStatuses) && firstAvailableStatus ? { status: firstAvailableStatus } : {}; @@ -96,7 +98,7 @@ export const AllCasesGeneric = React.memo( setFilters, setQueryParams, setSelectedCases, - } = useGetCases({}, initialFilterOptions); + } = useGetCases({ initialFilterOptions }); // Post Comment to Case const { postComment, isLoading: isCommentUpdating } = usePostComment(); diff --git a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx index 82db4a63115e4..5ed3215241de5 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -13,7 +13,7 @@ import '../../common/mock/match_media'; import { TestProviders } from '../../common/mock'; import { casesStatus, useGetCasesMockState, collectionCase } from '../../containers/mock'; -import { CaseStatuses, CaseType, StatusAll } from '../../../common'; +import { CaseStatuses, CaseType, SECURITY_SOLUTION_OWNER, StatusAll } from '../../../common'; import { getEmptyTagValue } from '../empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; import { useGetCases } from '../../containers/use_get_cases'; @@ -53,6 +53,7 @@ describe('AllCasesGeneric', () => { onClick: jest.fn(), }, userCanCrud: true, + owner: [SECURITY_SOLUTION_OWNER], }; const dispatchResetIsDeleted = jest.fn(); @@ -815,6 +816,7 @@ describe('AllCasesGeneric', () => { }, }, id: '1', + owner: SECURITY_SOLUTION_OWNER, status: 'open', subCaseIds: [], tags: ['coke', 'pepsi'], diff --git a/x-pack/plugins/cases/public/components/all_cases/index.tsx b/x-pack/plugins/cases/public/components/all_cases/index.tsx index 2c506cd2da411..3d6c039aa001c 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.tsx @@ -6,9 +6,11 @@ */ import React from 'react'; +import { Owner } from '../../types'; import { CaseDetailsHrefSchema, CasesNavigation } from '../links'; +import { OwnerProvider } from '../owner_context'; import { AllCasesGeneric } from './all_cases_generic'; -export interface AllCasesProps { +export interface AllCasesProps extends Owner { caseDetailsNavigation: CasesNavigation; // if not passed, case name is not displayed as a link (Formerly dependant on isSelector) configureCasesNavigation: CasesNavigation; // if not passed, header with nav is not displayed (Formerly dependant on isSelector) createCaseNavigation: CasesNavigation; @@ -16,7 +18,11 @@ export interface AllCasesProps { } export const AllCases: React.FC = (props) => { - return ; + return ( + + + + ); }; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.test.tsx index 8793632bc1afb..2b34fd8b86e89 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.test.tsx @@ -11,6 +11,7 @@ import { mount } from 'enzyme'; import { AllCasesSelectorModal } from '.'; import { TestProviders } from '../../../common/mock'; import { AllCasesGeneric } from '../all_cases_generic'; +import { SECURITY_SOLUTION_OWNER } from '../../../../common'; jest.mock('../../../methods'); jest.mock('../all_cases_generic'); @@ -20,6 +21,7 @@ const defaultProps = { createCaseNavigation, onRowClick, userCanCrud: true, + owner: [SECURITY_SOLUTION_OWNER], }; const updateCase = jest.fn(); @@ -59,6 +61,7 @@ describe('AllCasesSelectorModal', () => { }, index: 'index-id', alertId: 'alert-id', + owner: SECURITY_SOLUTION_OWNER, }, hiddenStatuses: [], updateCase, diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.tsx index d476d71d847a0..6e676ea58f14f 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/index.tsx @@ -17,8 +17,10 @@ import { import { CasesNavigation } from '../../links'; import * as i18n from '../../../common/translations'; import { AllCasesGeneric } from '../all_cases_generic'; +import { Owner } from '../../../types'; +import { OwnerProvider } from '../../owner_context'; -export interface AllCasesSelectorModalProps { +export interface AllCasesSelectorModalProps extends Owner { alertData?: Omit; createCaseNavigation: CasesNavigation; hiddenStatuses?: CaseStatusWithAllStatus[]; @@ -34,7 +36,7 @@ const Modal = styled(EuiModal)` `} `; -export const AllCasesSelectorModal: React.FC = ({ +const AllCasesSelectorModalComponent: React.FC = ({ alertData, createCaseNavigation, hiddenStatuses, @@ -70,5 +72,13 @@ export const AllCasesSelectorModal: React.FC = ({ ) : null; }; + +export const AllCasesSelectorModal: React.FC = React.memo((props) => { + return ( + + + + ); +}); // eslint-disable-next-line import/no-default-export export { AllCasesSelectorModal as default }; diff --git a/x-pack/plugins/cases/public/components/callout/helpers.tsx b/x-pack/plugins/cases/public/components/callout/helpers.tsx index 2a7804579a57e..3409c5eb94245 100644 --- a/x-pack/plugins/cases/public/components/callout/helpers.tsx +++ b/x-pack/plugins/cases/public/components/callout/helpers.tsx @@ -13,8 +13,8 @@ import { ErrorMessage } from './types'; export const savedObjectReadOnlyErrorMessage: ErrorMessage = { id: 'read-only-privileges-error', - title: i18n.READ_ONLY_SAVED_OBJECT_TITLE, - description: <>{i18n.READ_ONLY_SAVED_OBJECT_MSG}, + title: i18n.READ_ONLY_FEATURE_TITLE, + description: <>{i18n.READ_ONLY_FEATURE_MSG}, errorType: 'warning', }; diff --git a/x-pack/plugins/cases/public/components/callout/translations.ts b/x-pack/plugins/cases/public/components/callout/translations.ts index 6d4b55603a06f..dca622e60c863 100644 --- a/x-pack/plugins/cases/public/components/callout/translations.ts +++ b/x-pack/plugins/cases/public/components/callout/translations.ts @@ -7,17 +7,14 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_SAVED_OBJECT_TITLE = i18n.translate('xpack.cases.readOnlySavedObjectTitle', { +export const READ_ONLY_FEATURE_TITLE = i18n.translate('xpack.cases.readOnlyFeatureTitle', { defaultMessage: 'You cannot open new or update existing cases', }); -export const READ_ONLY_SAVED_OBJECT_MSG = i18n.translate( - 'xpack.cases.readOnlySavedObjectDescription', - { - defaultMessage: - 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', - } -); +export const READ_ONLY_FEATURE_MSG = i18n.translate('xpack.cases.readOnlyFeatureDescription', { + defaultMessage: + 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', +}); export const DISMISS_CALLOUT = i18n.translate('xpack.cases.dismissErrorsPushServiceCallOutTitle', { defaultMessage: 'Dismiss', diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx index 0f06dde6a86d1..a68ae4b3ca6a7 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx @@ -83,6 +83,7 @@ const CaseActionBarComponent: React.FC = ({ diff --git a/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx index 29cca46d372f0..54cbbc5b6841f 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.test.tsx @@ -26,6 +26,18 @@ describe('SyncAlertsSwitch', () => { expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).exists()).toBeTruthy(); }); + it('it renders when disabled', async () => { + const wrapper = mount( + + ); + + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).exists()).toBeTruthy(); + }); + it('it renders the current status correctly', async () => { const wrapper = mount( diff --git a/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx index 2922b797f9d40..65a220b65e403 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/status_context_menu.tsx @@ -13,16 +13,21 @@ import { Status } from '../status'; interface Props { currentStatus: CaseStatuses; + disabled?: boolean; onStatusChanged: (status: CaseStatuses) => void; } -const StatusContextMenuComponent: React.FC = ({ currentStatus, onStatusChanged }) => { +const StatusContextMenuComponent: React.FC = ({ + currentStatus, + onStatusChanged, + disabled = false, +}) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); const closePopover = useCallback(() => setIsPopoverOpen(false), []); const openPopover = useCallback(() => setIsPopoverOpen(true), []); const popOverButton = useMemo( - () => , - [currentStatus, openPopover] + () => , + [disabled, currentStatus, openPopover] ); const onContextMenuItemClick = useMemo( diff --git a/x-pack/plugins/cases/public/components/case_view/helpers.test.tsx b/x-pack/plugins/cases/public/components/case_view/helpers.test.tsx index f266c574c27da..bf5a9fe5d0a22 100644 --- a/x-pack/plugins/cases/public/components/case_view/helpers.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/helpers.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { AssociationType, CommentType } from '../../../common'; +import { AssociationType, CommentType, SECURITY_SOLUTION_OWNER } from '../../../common'; import { Comment } from '../../containers/types'; import { getManualAlertIdsWithNoRuleId } from './helpers'; @@ -28,6 +28,7 @@ const comments: Comment[] = [ updatedAt: null, updatedBy: null, version: 'WzQ3LDFc', + owner: SECURITY_SOLUTION_OWNER, }, { associationType: AssociationType.case, @@ -46,6 +47,7 @@ const comments: Comment[] = [ updatedAt: null, updatedBy: null, version: 'WzQ3LDFc', + owner: SECURITY_SOLUTION_OWNER, }, ]; diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index 557f736c513b9..86b13ae5a863c 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -43,6 +43,7 @@ import { Ecs } from '../../../common'; import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context'; import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { CasesNavigation } from '../links'; +import { OwnerProvider } from '../owner_context'; const gutterTimeline = '70px'; // seems to be a timeline reference from the original file export interface CaseViewComponentProps { @@ -450,6 +451,7 @@ export const CaseComponent = React.memo( tags={caseData.tags} onSubmit={onSubmitTags} isLoading={isLoading && updateKey === 'tags'} + owner={[caseData.owner]} /> - + + + ) ); diff --git a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx index e3abbeadd2d3c..6f02a209f22dc 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx @@ -45,6 +45,7 @@ export const useCaseConfigureResponse: ReturnUseCaseConfigure = { setCurrentConfiguration: jest.fn(), setMappings: jest.fn(), version: '', + id: '', }; export const useConnectorsResponse: UseConnectorsResponse = { diff --git a/x-pack/plugins/cases/public/components/configure_cases/button.tsx b/x-pack/plugins/cases/public/components/configure_cases/button.tsx index 1830380be3765..8b3c78ee3aede 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/button.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/button.tsx @@ -10,7 +10,6 @@ import React, { memo, useMemo } from 'react'; import { CasesNavigation, LinkButton } from '../links'; // TODO: Potentially move into links component? - export interface ConfigureCaseButtonProps { configureCasesNavigation: CasesNavigation; isDisabled: boolean; diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx index 898d6cde19a77..0d9ede9bb7de8 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx @@ -32,7 +32,7 @@ import { useConnectorsResponse, useActionTypesResponse, } from './__mock__'; -import { ConnectorTypes } from '../../../common'; +import { ConnectorTypes, SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/configure/use_connectors'); @@ -102,7 +102,9 @@ describe('ConfigureCases', () => { useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] })); useGetUrlSearchMock.mockImplementation(() => searchURL); - wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); }); test('it renders the Connectors', () => { @@ -155,7 +157,9 @@ describe('ConfigureCases', () => { })); useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, connectors: [] })); useGetUrlSearchMock.mockImplementation(() => searchURL); - wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); }); test('it shows the warning callout when configuration is invalid', () => { @@ -200,7 +204,9 @@ describe('ConfigureCases', () => { useConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); - wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); }); test('it renders with correct props', () => { @@ -220,9 +226,12 @@ describe('ConfigureCases', () => { }); test('it disables correctly when the user cannot crud', () => { - const newWrapper = mount(, { - wrappingComponent: TestProviders, - }); + const newWrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); expect(newWrapper.find('button[data-test-subj="dropdown-connectors"]').prop('disabled')).toBe( true @@ -282,7 +291,9 @@ describe('ConfigureCases', () => { })); useGetUrlSearchMock.mockImplementation(() => searchURL); - wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); }); test('it disables correctly Connector when loading connectors', () => { @@ -315,7 +326,9 @@ describe('ConfigureCases', () => { useActionTypesMock.mockImplementation(() => ({ ...useActionTypesResponse, loading: true })); - wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); expect(wrapper.find(Connectors).prop('isLoading')).toBe(true); }); }); @@ -337,7 +350,9 @@ describe('ConfigureCases', () => { useConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); - wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); }); test('it disables correctly Connector when saving configuration', () => { @@ -378,7 +393,9 @@ describe('ConfigureCases', () => { ...useConnectorsResponse, })); useGetUrlSearchMock.mockImplementation(() => searchURL); - wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); }); test('it hides the update connector button when loading the configuration', () => { @@ -420,7 +437,9 @@ describe('ConfigureCases', () => { useConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); - wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); }); test('it submits the configuration correctly when changing connector', () => { @@ -462,7 +481,9 @@ describe('ConfigureCases', () => { }, })); - wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); @@ -508,7 +529,9 @@ describe('closure options', () => { useConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); - wrapper = mount(, { wrappingComponent: TestProviders }); + wrapper = mount(, { + wrappingComponent: TestProviders, + }); }); test('it submits the configuration correctly when changing closure type', () => { @@ -555,7 +578,9 @@ describe('user interactions', () => { }); test('it show the add flyout when pressing the add connector button', () => { - const wrapper = mount(, { wrappingComponent: TestProviders }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.update(); wrapper.find('button[data-test-subj="dropdown-connector-add-connector"]').simulate('click'); @@ -576,7 +601,9 @@ describe('user interactions', () => { }); test('it show the edit flyout when pressing the update connector button', () => { - const wrapper = mount(, { wrappingComponent: TestProviders }); + const wrapper = mount(, { + wrappingComponent: TestProviders, + }); wrapper .find('button[data-test-subj="case-configure-update-selected-connector-button"]') .simulate('click'); diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx index fdba148e5c61e..3ee4bc77cd237 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -31,6 +31,8 @@ import { normalizeCaseConnector, } from './utils'; import * as i18n from './translations'; +import { Owner } from '../../types'; +import { OwnerProvider } from '../owner_context'; const FormWrapper = styled.div` ${({ theme }) => css` @@ -50,11 +52,11 @@ const FormWrapper = styled.div` `} `; -export interface ConfigureCasesProps { +export interface ConfigureCasesProps extends Owner { userCanCrud: boolean; } -const ConfigureCasesComponent: React.FC = ({ userCanCrud }) => { +const ConfigureCasesComponent: React.FC> = ({ userCanCrud }) => { const { triggersActionsUi } = useKibana().services; const [connectorIsValid, setConnectorIsValid] = useState(true); @@ -223,6 +225,13 @@ const ConfigureCasesComponent: React.FC = ({ userCanCrud }) ); }; -export const ConfigureCases = React.memo(ConfigureCasesComponent); +export const ConfigureCases: React.FC = React.memo((props) => { + return ( + + + + ); +}); + // eslint-disable-next-line import/no-default-export export default ConfigureCases; diff --git a/x-pack/plugins/cases/public/components/connectors/case/alert_fields.tsx b/x-pack/plugins/cases/public/components/connectors/case/alert_fields.tsx index 0c44bcab70679..8fb34e0cdcbf5 100644 --- a/x-pack/plugins/cases/public/components/connectors/case/alert_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/case/alert_fields.tsx @@ -12,12 +12,13 @@ import styled from 'styled-components'; import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { ActionParamsProps } from '../../../../../triggers_actions_ui/public/types'; -import { CommentType } from '../../../../common'; +import { CommentType, SECURITY_SOLUTION_OWNER } from '../../../../common'; import { CaseActionParams } from './types'; import { ExistingCase } from './existing_case'; import * as i18n from './translations'; +import { OwnerProvider } from '../../owner_context'; const Container = styled.div` ${({ theme }) => ` @@ -89,9 +90,15 @@ const CaseParamsFields: React.FunctionComponent - + + +

{i18n.CASE_CONNECTOR_CALL_OUT_MSG}

diff --git a/x-pack/plugins/cases/public/components/connectors/case/existing_case.tsx b/x-pack/plugins/cases/public/components/connectors/case/existing_case.tsx index 22798843dd856..aafbfb8b43b78 100644 --- a/x-pack/plugins/cases/public/components/connectors/case/existing_case.tsx +++ b/x-pack/plugins/cases/public/components/connectors/case/existing_case.tsx @@ -21,9 +21,12 @@ interface ExistingCaseProps { } const ExistingCaseComponent: React.FC = ({ onCaseChanged, selectedCase }) => { - const { data: cases, loading: isLoadingCases, refetchCases } = useGetCases(DEFAULT_QUERY_PARAMS, { - ...DEFAULT_FILTER_OPTIONS, - onlyCollectionType: true, + const { data: cases, loading: isLoadingCases, refetchCases } = useGetCases({ + initialQueryParams: DEFAULT_QUERY_PARAMS, + initialFilterOptions: { + ...DEFAULT_FILTER_OPTIONS, + onlyCollectionType: true, + }, }); const onCaseCreated = useCallback( diff --git a/x-pack/plugins/cases/public/components/create/flyout.test.tsx b/x-pack/plugins/cases/public/components/create/flyout.test.tsx deleted file mode 100644 index 5187029ab60c7..0000000000000 --- a/x-pack/plugins/cases/public/components/create/flyout.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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, { ReactNode } from 'react'; -import { mount } from 'enzyme'; - -import { CreateCaseFlyout } from './flyout'; -import { TestProviders } from '../../common/mock'; - -jest.mock('../create/form_context', () => { - return { - FormContext: ({ - children, - onSuccess, - }: { - children: ReactNode; - onSuccess: ({ id }: { id: string }) => Promise; - }) => { - return ( - <> - - {children} - - ); - }, - }; -}); - -jest.mock('../create/form', () => { - return { - CreateCaseForm: () => { - return <>{'form'}; - }, - }; -}); - -jest.mock('../create/submit_button', () => { - return { - SubmitCaseButton: () => { - return <>{'Submit'}; - }, - }; -}); - -const onCloseFlyout = jest.fn(); -const onSuccess = jest.fn(); -const defaultProps = { - onCloseFlyout, - onSuccess, -}; - -describe('CreateCaseFlyout', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('renders', () => { - const wrapper = mount( - - - - ); - - expect(wrapper.find(`[data-test-subj='create-case-flyout']`).exists()).toBeTruthy(); - }); - - it('Closing modal calls onCloseCaseModal', () => { - const wrapper = mount( - - - - ); - - wrapper.find('.euiFlyout__closeButton').first().simulate('click'); - expect(onCloseFlyout).toBeCalled(); - }); - - it('pass the correct props to FormContext component', () => { - const wrapper = mount( - - - - ); - - const props = wrapper.find('FormContext').props(); - expect(props).toEqual( - expect.objectContaining({ - onSuccess, - }) - ); - }); - - it('onSuccess called when creating a case', () => { - const wrapper = mount( - - - - ); - - wrapper.find(`[data-test-subj='form-context-on-success']`).first().simulate('click'); - expect(onSuccess).toHaveBeenCalledWith({ id: 'case-id' }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/create/flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout.tsx deleted file mode 100644 index 8ed09865e9eab..0000000000000 --- a/x-pack/plugins/cases/public/components/create/flyout.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 } from 'react'; -import styled from 'styled-components'; -import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui'; - -import { FormContext } from '../create/form_context'; -import { CreateCaseForm } from '../create/form'; -import { SubmitCaseButton } from '../create/submit_button'; -import { Case } from '../../containers/types'; -import * as i18n from '../../common/translations'; - -export interface CreateCaseModalProps { - onCloseFlyout: () => void; - onSuccess: (theCase: Case) => Promise; - afterCaseCreated?: (theCase: Case) => Promise; -} - -const Container = styled.div` - ${({ theme }) => ` - margin-top: ${theme.eui.euiSize}; - text-align: right; - `} -`; - -const StyledFlyout = styled(EuiFlyout)` - ${({ theme }) => ` - z-index: ${theme.eui.euiZModal}; - `} -`; - -// Adding bottom padding because timeline's -// bottom bar gonna hide the submit button. -const FormWrapper = styled.div` - padding-bottom: 50px; -`; - -const CreateCaseFlyoutComponent: React.FC = ({ - onSuccess, - afterCaseCreated, - onCloseFlyout, -}) => { - return ( - - - -

{i18n.CREATE_TITLE}

-
-
- - - - - - - - - - -
- ); -}; - -export const CreateCaseFlyout = memo(CreateCaseFlyoutComponent); - -CreateCaseFlyout.displayName = 'CreateCaseFlyout'; diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index 9e59924bdf483..5f3b778a7cafc 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -15,6 +15,8 @@ import { useConnectors } from '../../containers/configure/use_connectors'; import { connectorsMock } from '../../containers/mock'; import { schema, FormProps } from './schema'; import { CreateCaseForm } from './form'; +import { OwnerProvider } from '../owner_context'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_connectors'); @@ -41,7 +43,11 @@ describe('CreateCaseForm', () => { globalForm = form; - return
{children}
; + return ( + +
{children}
+
+ ); }; beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index 9a8671c7fc571..cb053b2e784cd 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -10,7 +10,7 @@ import { mount, ReactWrapper } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { ConnectorTypes } from '../../../common'; +import { ConnectorTypes, SECURITY_SOLUTION_OWNER } from '../../../common'; import { TestProviders } from '../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; import { usePostComment } from '../../containers/use_post_comment'; @@ -77,6 +77,7 @@ const defaultPostCase = { const defaultCreateCaseForm = { isLoadingConnectors: false, connectors: [], + owner: SECURITY_SOLUTION_OWNER, }; const defaultPostPushToService = { diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index 7ca3fe4b88c8d..8584892e1286c 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -21,6 +21,7 @@ import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Case } from '../../containers/types'; import { CaseType, ConnectorTypes } from '../../../common'; import { UsePostComment, usePostComment } from '../../containers/use_post_comment'; +import { useOwnerContext } from '../owner_context/use_owner_context'; const initialCaseValue: FormProps = { description: '', @@ -47,6 +48,7 @@ export const FormContext: React.FC = ({ onSuccess, }) => { const { connectors, loading: isLoadingConnectors } = useConnectors(); + const owner = useOwnerContext(); const { connector: configurationConnector } = useCaseConfigure(); const { postCase } = usePostCase(); const { postComment } = usePostComment(); @@ -86,6 +88,7 @@ export const FormContext: React.FC = ({ type: caseType, connector: connectorToUpdate, settings: { syncAlerts }, + owner: owner[0], }); if (afterCaseCreated && updatedCase) { @@ -105,13 +108,14 @@ export const FormContext: React.FC = ({ } }, [ - caseType, connectors, postCase, - postComment, + caseType, + owner, + afterCaseCreated, onSuccess, + postComment, pushCaseToExternalService, - afterCaseCreated, ] ); diff --git a/x-pack/plugins/cases/public/components/create/index.test.tsx b/x-pack/plugins/cases/public/components/create/index.test.tsx index e82af8edc6337..350b971bb05fc 100644 --- a/x-pack/plugins/cases/public/components/create/index.test.tsx +++ b/x-pack/plugins/cases/public/components/create/index.test.tsx @@ -29,6 +29,7 @@ import { useGetFieldsByIssueTypeResponse, } from './mock'; import { CreateCase } from '.'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../containers/api'); jest.mock('../../containers/use_get_tags'); @@ -91,7 +92,7 @@ describe('CreateCase case', () => { it('it renders', async () => { const wrapper = mount( - + ); @@ -102,7 +103,7 @@ describe('CreateCase case', () => { it('should call cancel on cancel click', async () => { const wrapper = mount( - + ); @@ -113,7 +114,7 @@ describe('CreateCase case', () => { it('should redirect to new case when posting the case', async () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/cases/public/components/create/index.tsx b/x-pack/plugins/cases/public/components/create/index.tsx index a1de4d9730b9f..3362aa6af2078 100644 --- a/x-pack/plugins/cases/public/components/create/index.tsx +++ b/x-pack/plugins/cases/public/components/create/index.tsx @@ -20,6 +20,8 @@ import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../t import { fieldName as descriptionFieldName } from './description'; import { InsertTimeline } from '../insert_timeline'; import { UsePostComment } from '../../containers/use_post_comment'; +import { Owner } from '../../types'; +import { OwnerProvider } from '../owner_context'; export const CommonUseField = getUseField({ component: Field }); @@ -29,7 +31,7 @@ const Container = styled.div` `} `; -export interface CreateCaseProps { +export interface CreateCaseProps extends Owner { afterCaseCreated?: (theCase: Case, postComment: UsePostComment['postComment']) => Promise; caseType?: CaseType; hideConnectorServiceNowSir?: boolean; @@ -39,7 +41,7 @@ export interface CreateCaseProps { withSteps?: boolean; } -export const CreateCase = ({ +const CreateCaseComponent = ({ afterCaseCreated, caseType, hideConnectorServiceNowSir, @@ -47,7 +49,7 @@ export const CreateCase = ({ onSuccess, timelineIntegration, withSteps, -}: CreateCaseProps) => ( +}: Omit) => ( ); +export const CreateCase: React.FC = React.memo((props) => { + return ( + + + + ); +}); // eslint-disable-next-line import/no-default-export export { CreateCase as default }; diff --git a/x-pack/plugins/cases/public/components/create/mock.ts b/x-pack/plugins/cases/public/components/create/mock.ts index eb40fa097d3cc..fb00f114f480c 100644 --- a/x-pack/plugins/cases/public/components/create/mock.ts +++ b/x-pack/plugins/cases/public/components/create/mock.ts @@ -5,7 +5,12 @@ * 2.0. */ -import { CasePostRequest, CaseType, ConnectorTypes } from '../../../common'; +import { + CasePostRequest, + CaseType, + ConnectorTypes, + SECURITY_SOLUTION_OWNER, +} from '../../../common'; import { choices } from '../connectors/mock'; export const sampleTags = ['coke', 'pepsi']; @@ -23,6 +28,7 @@ export const sampleData: CasePostRequest = { settings: { syncAlerts: true, }, + owner: SECURITY_SOLUTION_OWNER, }; export const sampleConnectorData = { loading: false, connectors: [] }; diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index 7ca1e2e061545..6e6d1a414280e 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -19,7 +19,7 @@ export const schemaTags = { labelAppend: OptionalFieldLabel, }; -export type FormProps = Omit & { +export type FormProps = Omit & { connectorId: string; fields: ConnectorTypeFields['fields']; syncAlerts: boolean; diff --git a/x-pack/plugins/cases/public/components/create/tags.test.tsx b/x-pack/plugins/cases/public/components/create/tags.test.tsx index 2eddb83dcac29..6efbf1b8c7107 100644 --- a/x-pack/plugins/cases/public/components/create/tags.test.tsx +++ b/x-pack/plugins/cases/public/components/create/tags.test.tsx @@ -14,6 +14,8 @@ import { useForm, Form, FormHook } from '../../common/shared_imports'; import { useGetTags } from '../../containers/use_get_tags'; import { Tags } from './tags'; import { schema, FormProps } from './schema'; +import { OwnerProvider } from '../owner_context'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../containers/use_get_tags'); const useGetTagsMock = useGetTags as jest.Mock; @@ -31,7 +33,11 @@ describe('Tags', () => { globalForm = form; - return
{children}
; + return ( + +
{children}
+
+ ); }; beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/owner_context/index.tsx b/x-pack/plugins/cases/public/components/owner_context/index.tsx new file mode 100644 index 0000000000000..5df7eeadd70d5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/owner_context/index.tsx @@ -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 React, { useState } from 'react'; + +export const OwnerContext = React.createContext([]); + +export const OwnerProvider: React.FC<{ + owner: string[]; +}> = ({ children, owner }) => { + const [currentOwner] = useState(owner); + + return {children}; +}; diff --git a/x-pack/plugins/cases/public/components/owner_context/use_owner_context.ts b/x-pack/plugins/cases/public/components/owner_context/use_owner_context.ts new file mode 100644 index 0000000000000..a443df1809315 --- /dev/null +++ b/x-pack/plugins/cases/public/components/owner_context/use_owner_context.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useContext } from 'react'; +import { OwnerContext } from '.'; + +export const useOwnerContext = () => { + const ownerContext = useContext(OwnerContext); + + if (ownerContext.length === 0) { + throw new Error( + 'useOwnerContext must be used within an OwnerProvider and not be an empty array' + ); + } + + return ownerContext; +}; diff --git a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx index 933ea51bffac4..5893d5f8c5af4 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx @@ -12,6 +12,8 @@ import RecentCases from '.'; import { TestProviders } from '../../common/mock'; import { useGetCases } from '../../containers/use_get_cases'; import { useGetCasesMockState } from '../../containers/mock'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; + jest.mock('../../containers/use_get_cases'); configure({ testIdAttribute: 'data-test-subj' }); const defaultProps = { @@ -28,6 +30,7 @@ const defaultProps = { onClick: jest.fn(), }, maxCasesToShow: 10, + owner: [SECURITY_SOLUTION_OWNER], }; const setFilters = jest.fn(); const mockData = { @@ -40,6 +43,7 @@ describe('RecentCases', () => { jest.clearAllMocks(); useGetCasesMock.mockImplementation(() => mockData); }); + it('is good at loading', () => { useGetCasesMock.mockImplementation(() => ({ ...mockData, @@ -52,6 +56,7 @@ describe('RecentCases', () => { ); expect(getAllByTestId('loadingPlaceholders')).toHaveLength(3); }); + it('is good at rendering cases', () => { const { getAllByTestId } = render( @@ -60,14 +65,18 @@ describe('RecentCases', () => { ); expect(getAllByTestId('case-details-link')).toHaveLength(5); }); + it('is good at rendering max cases', () => { render( ); - expect(useGetCasesMock).toBeCalledWith({ perPage: 2 }); + expect(useGetCasesMock).toBeCalledWith({ + initialQueryParams: { perPage: 2 }, + }); }); + it('updates filters', () => { const { getByTestId } = render( diff --git a/x-pack/plugins/cases/public/components/recent_cases/index.tsx b/x-pack/plugins/cases/public/components/recent_cases/index.tsx index 05aff25d0dbd8..bb34f651d52df 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/index.tsx @@ -14,20 +14,22 @@ import { RecentCasesFilters } from './filters'; import { RecentCasesComp } from './recent_cases'; import { FilterMode as RecentCasesFilterMode } from './types'; import { useCurrentUser } from '../../common/lib/kibana'; +import { Owner } from '../../types'; +import { OwnerProvider } from '../owner_context'; -export interface RecentCasesProps { +export interface RecentCasesProps extends Owner { allCasesNavigation: CasesNavigation; caseDetailsNavigation: CasesNavigation; createCaseNavigation: CasesNavigation; maxCasesToShow: number; } -const RecentCases = ({ +const RecentCasesComponent = ({ allCasesNavigation, caseDetailsNavigation, createCaseNavigation, maxCasesToShow, -}: RecentCasesProps) => { +}: Omit) => { const currentUser = useCurrentUser(); const [recentCasesFilterBy, setRecentCasesFilterBy] = useState( 'recentlyCreated' @@ -87,5 +89,13 @@ const RecentCases = ({ ); }; +export const RecentCases: React.FC = React.memo((props) => { + return ( + + + + ); +}); + // eslint-disable-next-line import/no-default-export export { RecentCases as default }; diff --git a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx index 12935e75c064f..5b4313530e490 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx @@ -32,6 +32,7 @@ export interface RecentCasesProps { createCaseNavigation: CasesNavigation; maxCasesToShow: number; } + const usePrevious = (value: Partial) => { const ref = useRef(); useEffect(() => { @@ -46,7 +47,9 @@ export const RecentCasesComp = ({ maxCasesToShow, }: RecentCasesProps) => { const previousFilterOptions = usePrevious(filterOptions); - const { data, loading, setFilters } = useGetCases({ perPage: maxCasesToShow }); + const { data, loading, setFilters } = useGetCases({ + initialQueryParams: { perPage: maxCasesToShow }, + }); useEffect(() => { if (previousFilterOptions !== undefined && !isEqual(previousFilterOptions, filterOptions)) { diff --git a/x-pack/plugins/cases/public/components/status/status.test.tsx b/x-pack/plugins/cases/public/components/status/status.test.tsx index 7cddbf5ca4a1d..4d13e57fbdee7 100644 --- a/x-pack/plugins/cases/public/components/status/status.test.tsx +++ b/x-pack/plugins/cases/public/components/status/status.test.tsx @@ -31,6 +31,30 @@ describe('Stats', () => { ).toBeTruthy(); }); + it('it renders with the pop over enabled by default', async () => { + const wrapper = mount(); + + expect( + wrapper + .find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`) + .first() + .prop('disabled') + ).toBe(false); + }); + + it('it renders with the pop over disabled when initialized disabled', async () => { + const wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`) + .first() + .prop('disabled') + ).toBe(true); + }); + it('it calls onClick when pressing the badge', async () => { const wrapper = mount(); diff --git a/x-pack/plugins/cases/public/components/status/status.tsx b/x-pack/plugins/cases/public/components/status/status.tsx index 03dca8642aed7..3b832ce155400 100644 --- a/x-pack/plugins/cases/public/components/status/status.tsx +++ b/x-pack/plugins/cases/public/components/status/status.tsx @@ -14,12 +14,18 @@ import * as i18n from './translations'; import { CaseStatusWithAllStatus, StatusAll } from '../../../common'; interface Props { + disabled?: boolean; type: CaseStatusWithAllStatus; withArrow?: boolean; onClick?: () => void; } -const StatusComponent: React.FC = ({ type, withArrow = false, onClick = noop }) => { +const StatusComponent: React.FC = ({ + type, + disabled = false, + withArrow = false, + onClick = noop, +}) => { const props = useMemo( () => ({ color: type === StatusAll ? allCaseStatus[StatusAll].color : statuses[type].color, @@ -34,6 +40,7 @@ const StatusComponent: React.FC = ({ type, withArrow = false, onClick = n iconOnClick={onClick} iconOnClickAriaLabel={i18n.STATUS_ICON_ARIA} data-test-subj={`status-badge-${type}`} + isDisabled={disabled} > {type === StatusAll ? allCaseStatus[StatusAll].label : statuses[type].label} diff --git a/x-pack/plugins/cases/public/components/tag_list/index.test.tsx b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx index 296c4ba0e893b..b3fbcd30d4e97 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx @@ -14,6 +14,7 @@ import { TestProviders } from '../../common/mock'; import { waitFor } from '@testing-library/react'; import { useForm } from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; import { useGetTags } from '../../containers/use_get_tags'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'); jest.mock('../../containers/use_get_tags'); @@ -37,6 +38,7 @@ const defaultProps = { isLoading: false, onSubmit, tags: [], + owner: [SECURITY_SOLUTION_OWNER], }; describe('TagList ', () => { diff --git a/x-pack/plugins/cases/public/components/tag_list/index.tsx b/x-pack/plugins/cases/public/components/tag_list/index.tsx index 137d58932b6ef..f260593369679 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.tsx @@ -32,6 +32,7 @@ interface TagListProps { isLoading: boolean; onSubmit: (a: string[]) => void; tags: string[]; + owner: string[]; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -44,7 +45,7 @@ const MyFlexGroup = styled(EuiFlexGroup)` `; export const TagList = React.memo( - ({ disabled = false, isLoading, onSubmit, tags }: TagListProps) => { + ({ disabled = false, isLoading, onSubmit, tags, owner }: TagListProps) => { const initialState = { tags }; const { form } = useForm({ defaultValue: initialState, diff --git a/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.test.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.test.tsx index 661a0eedfeae4..4c39b721cac47 100644 --- a/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.test.tsx @@ -11,6 +11,7 @@ import { mount } from 'enzyme'; import { CreateCaseModal } from './create_case_modal'; import { TestProviders } from '../../common/mock'; import { getCreateCaseLazy as getCreateCase } from '../../methods'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../methods'); const getCreateCaseMock = getCreateCase as jest.Mock; @@ -20,6 +21,7 @@ const defaultProps = { isModalOpen: true, onCloseCaseModal, onSuccess, + owner: SECURITY_SOLUTION_OWNER, }; describe('CreateCaseModal', () => { diff --git a/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.tsx index e78b432b3a27c..a4278e53ea341 100644 --- a/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.tsx +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/create_case_modal.tsx @@ -19,6 +19,7 @@ export interface CreateCaseModalProps { isModalOpen: boolean; onCloseCaseModal: () => void; onSuccess: (theCase: Case) => Promise; + owner: string; } const CreateModalComponent: React.FC = ({ @@ -27,6 +28,7 @@ const CreateModalComponent: React.FC = ({ isModalOpen, onCloseCaseModal, onSuccess, + owner, }) => { return isModalOpen ? ( @@ -40,6 +42,7 @@ const CreateModalComponent: React.FC = ({ onCancel: onCloseCaseModal, onSuccess, withSteps: false, + owner: [owner], })} diff --git a/x-pack/plugins/cases/public/components/use_create_case_modal/index.tsx b/x-pack/plugins/cases/public/components/use_create_case_modal/index.tsx index 7ad85773a7917..09f8eb65b12b7 100644 --- a/x-pack/plugins/cases/public/components/use_create_case_modal/index.tsx +++ b/x-pack/plugins/cases/public/components/use_create_case_modal/index.tsx @@ -7,6 +7,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { Case, CaseType } from '../../../common'; +import { useOwnerContext } from '../owner_context/use_owner_context'; import { CreateCaseModal } from './create_case_modal'; export interface UseCreateCaseModalProps { @@ -26,6 +27,7 @@ export const useCreateCaseModal = ({ onCaseCreated, hideConnectorServiceNowSir = false, }: UseCreateCaseModalProps) => { + const owner = useOwnerContext(); const [isModalOpen, setIsModalOpen] = useState(false); const closeModal = useCallback(() => setIsModalOpen(false), []); const openModal = useCallback(() => setIsModalOpen(true), []); @@ -46,12 +48,13 @@ export const useCreateCaseModal = ({ isModalOpen={isModalOpen} onCloseCaseModal={closeModal} onSuccess={onSuccess} + owner={owner[0]} /> ), isModalOpen, closeModal, openModal, }), - [caseType, closeModal, hideConnectorServiceNowSir, isModalOpen, onSuccess, openModal] + [caseType, closeModal, hideConnectorServiceNowSir, isModalOpen, onSuccess, openModal, owner] ); }; diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts index 4dbb10da95b2d..006ad3f7afe60 100644 --- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -62,6 +62,7 @@ export const getCases = async ({ reporters: [], status: CaseStatuses.open, tags: [], + owner: [], }, queryParams = { page: 1, diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index 3e71a05df7cc1..afd6b51b5f35d 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -7,7 +7,7 @@ import { KibanaServices } from '../common/lib/kibana'; -import { ConnectorTypes, CommentType, CaseStatuses } from '../../common'; +import { ConnectorTypes, CommentType, CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../common'; import { CASES_URL } from '../../common'; import { @@ -127,7 +127,7 @@ describe('Case Configuration API', () => { }); test('check url, method, signal', async () => { await getCases({ - filterOptions: DEFAULT_FILTER_OPTIONS, + filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, }); @@ -137,6 +137,7 @@ describe('Case Configuration API', () => { ...DEFAULT_QUERY_PARAMS, reporters: [], tags: [], + owner: [SECURITY_SOLUTION_OWNER], }, signal: abortCtrl.signal, }); @@ -150,6 +151,7 @@ describe('Case Configuration API', () => { tags, status: CaseStatuses.open, search: 'hello', + owner: [SECURITY_SOLUTION_OWNER], }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, @@ -162,6 +164,7 @@ describe('Case Configuration API', () => { tags: ['"coke"', '"pepsi"'], search: 'hello', status: CaseStatuses.open, + owner: [SECURITY_SOLUTION_OWNER], }, signal: abortCtrl.signal, }); @@ -177,6 +180,7 @@ describe('Case Configuration API', () => { tags: weirdTags, status: CaseStatuses.open, search: 'hello', + owner: [SECURITY_SOLUTION_OWNER], }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, @@ -189,6 +193,7 @@ describe('Case Configuration API', () => { tags: ['"("', '"\\"double\\""'], search: 'hello', status: CaseStatuses.open, + owner: [SECURITY_SOLUTION_OWNER], }, signal: abortCtrl.signal, }); @@ -196,7 +201,7 @@ describe('Case Configuration API', () => { test('happy path', async () => { const resp = await getCases({ - filterOptions: DEFAULT_FILTER_OPTIONS, + filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, }); @@ -210,15 +215,16 @@ describe('Case Configuration API', () => { fetchMock.mockResolvedValue(casesStatusSnake); }); test('check url, method, signal', async () => { - await getCasesStatus(abortCtrl.signal); + await getCasesStatus(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/status`, { method: 'GET', signal: abortCtrl.signal, + query: { owner: [SECURITY_SOLUTION_OWNER] }, }); }); test('happy path', async () => { - const resp = await getCasesStatus(abortCtrl.signal); + const resp = await getCasesStatus(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); expect(resp).toEqual(casesStatus); }); }); @@ -250,15 +256,18 @@ describe('Case Configuration API', () => { }); test('check url, method, signal', async () => { - await getReporters(abortCtrl.signal); + await getReporters(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/reporters`, { method: 'GET', signal: abortCtrl.signal, + query: { + owner: [SECURITY_SOLUTION_OWNER], + }, }); }); test('happy path', async () => { - const resp = await getReporters(abortCtrl.signal); + const resp = await getReporters(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); expect(resp).toEqual(respReporters); }); }); @@ -270,15 +279,18 @@ describe('Case Configuration API', () => { }); test('check url, method, signal', async () => { - await getTags(abortCtrl.signal); + await getTags(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/tags`, { method: 'GET', signal: abortCtrl.signal, + query: { + owner: [SECURITY_SOLUTION_OWNER], + }, }); }); test('happy path', async () => { - const resp = await getTags(abortCtrl.signal); + const resp = await getTags(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); expect(resp).toEqual(tags); }); }); @@ -395,6 +407,7 @@ describe('Case Configuration API', () => { settings: { syncAlerts: true, }, + owner: SECURITY_SOLUTION_OWNER, }; test('check url, method, signal', async () => { @@ -419,6 +432,7 @@ describe('Case Configuration API', () => { }); const data = { comment: 'comment', + owner: SECURITY_SOLUTION_OWNER, type: CommentType.user as const, }; diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index 75263d4d38978..66a4d174b0ffb 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -111,26 +111,32 @@ export const getSubCase = async ( return convertToCamelCase(decodeCaseResponse(response)); }; -export const getCasesStatus = async (signal: AbortSignal): Promise => { +export const getCasesStatus = async ( + signal: AbortSignal, + owner: string[] +): Promise => { const response = await KibanaServices.get().http.fetch(CASE_STATUS_URL, { method: 'GET', signal, + query: { ...(owner.length > 0 ? { owner } : {}) }, }); return convertToCamelCase(decodeCasesStatusResponse(response)); }; -export const getTags = async (signal: AbortSignal): Promise => { +export const getTags = async (signal: AbortSignal, owner: string[]): Promise => { const response = await KibanaServices.get().http.fetch(CASE_TAGS_URL, { method: 'GET', signal, + query: { ...(owner.length > 0 ? { owner } : {}) }, }); return response ?? []; }; -export const getReporters = async (signal: AbortSignal): Promise => { +export const getReporters = async (signal: AbortSignal, owner: string[]): Promise => { const response = await KibanaServices.get().http.fetch(CASE_REPORTERS_URL, { method: 'GET', signal, + query: { ...(owner.length > 0 ? { owner } : {}) }, }); return response ?? []; }; @@ -171,6 +177,7 @@ export const getCases = async ({ reporters: [], status: StatusAll, tags: [], + owner: [], }, queryParams = { page: 1, @@ -186,6 +193,7 @@ export const getCases = async ({ status: filterOptions.status, ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}), ...(filterOptions.onlyCollectionType ? { type: CaseType.collection } : {}), + ...(filterOptions.owner.length > 0 ? { owner: filterOptions.owner } : {}), ...queryParams, }; const response = await KibanaServices.get().http.fetch(`${CASES_URL}/_find`, { diff --git a/x-pack/plugins/cases/public/containers/configure/api.test.ts b/x-pack/plugins/cases/public/containers/configure/api.test.ts index ae749b4391776..ad13526b41d38 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.test.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.test.ts @@ -19,7 +19,7 @@ import { caseConfigurationResposeMock, caseConfigurationCamelCaseResponseMock, } from './mock'; -import { ConnectorTypes } from '../../../common'; +import { ConnectorTypes, SECURITY_SOLUTION_OWNER } from '../../../common'; import { KibanaServices } from '../../common/lib/kibana'; const abortCtrl = new AbortController(); @@ -53,25 +53,34 @@ describe('Case Configuration API', () => { describe('fetch configuration', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue(caseConfigurationResposeMock); + fetchMock.mockResolvedValue([caseConfigurationResposeMock]); }); test('check url, method, signal', async () => { - await getCaseConfigure({ signal: abortCtrl.signal }); + await getCaseConfigure({ signal: abortCtrl.signal, owner: [SECURITY_SOLUTION_OWNER] }); expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { method: 'GET', signal: abortCtrl.signal, + query: { + owner: [SECURITY_SOLUTION_OWNER], + }, }); }); test('happy path', async () => { - const resp = await getCaseConfigure({ signal: abortCtrl.signal }); + const resp = await getCaseConfigure({ + signal: abortCtrl.signal, + owner: [SECURITY_SOLUTION_OWNER], + }); expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); }); test('return null on empty response', async () => { fetchMock.mockResolvedValue({}); - const resp = await getCaseConfigure({ signal: abortCtrl.signal }); + const resp = await getCaseConfigure({ + signal: abortCtrl.signal, + owner: [SECURITY_SOLUTION_OWNER], + }); expect(resp).toBe(null); }); }); @@ -86,7 +95,7 @@ describe('Case Configuration API', () => { await postCaseConfigure(caseConfigurationMock, abortCtrl.signal); expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { body: - '{"connector":{"id":"123","name":"My connector","type":".jira","fields":null},"closure_type":"close-by-user"}', + '{"connector":{"id":"123","name":"My connector","type":".jira","fields":null},"owner":"securitySolution","closure_type":"close-by-user"}', method: 'POST', signal: abortCtrl.signal, }); @@ -106,13 +115,14 @@ describe('Case Configuration API', () => { test('check url, body, method, signal', async () => { await patchCaseConfigure( + '123', { connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, version: 'WzHJ12', }, abortCtrl.signal ); - expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { + expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure/123', { body: '{"connector":{"id":"456","name":"My Connector 2","type":".none","fields":null},"version":"WzHJ12"}', method: 'PATCH', @@ -122,6 +132,7 @@ describe('Case Configuration API', () => { test('happy path', async () => { const resp = await patchCaseConfigure( + '123', { connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, version: 'WzHJ12', diff --git a/x-pack/plugins/cases/public/containers/configure/api.ts b/x-pack/plugins/cases/public/containers/configure/api.ts index ca8b7e3a05734..c972e2fc5c5fb 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.ts @@ -15,11 +15,17 @@ import { CasesConfigurePatch, CasesConfigureRequest, CasesConfigureResponse, + CasesConfigurationsResponse, + getCaseConfigurationDetailsUrl, } from '../../../common'; import { KibanaServices } from '../../common/lib/kibana'; import { ApiProps } from '../types'; -import { convertToCamelCase, decodeCaseConfigureResponse } from '../utils'; +import { + convertToCamelCase, + decodeCaseConfigurationsResponse, + decodeCaseConfigureResponse, +} from '../utils'; import { CaseConfigure } from './types'; export const fetchConnectors = async ({ signal }: ApiProps): Promise => { @@ -31,20 +37,27 @@ export const fetchConnectors = async ({ signal }: ApiProps): Promise => { - const response = await KibanaServices.get().http.fetch( +export const getCaseConfigure = async ({ + signal, + owner, +}: ApiProps & { owner: string[] }): Promise => { + const response = await KibanaServices.get().http.fetch( CASE_CONFIGURE_URL, { method: 'GET', signal, + query: { ...(owner.length > 0 ? { owner } : {}) }, } ); - return !isEmpty(response) - ? convertToCamelCase( - decodeCaseConfigureResponse(response) - ) - : null; + if (!isEmpty(response)) { + const decodedConfigs = decodeCaseConfigurationsResponse(response); + if (Array.isArray(decodedConfigs) && decodedConfigs.length > 0) { + return convertToCamelCase(decodedConfigs[0]); + } + } + + return null; }; export const getConnectorMappings = async ({ signal }: ApiProps): Promise => { @@ -74,11 +87,12 @@ export const postCaseConfigure = async ( }; export const patchCaseConfigure = async ( + id: string, caseConfiguration: CasesConfigurePatch, signal: AbortSignal ): Promise => { const response = await KibanaServices.get().http.fetch( - CASE_CONFIGURE_URL, + getCaseConfigurationDetailsUrl(id), { method: 'PATCH', body: JSON.stringify(caseConfiguration), diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index 766452e3e58e7..ef287ea866dcb 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -11,6 +11,7 @@ import { CasesConfigureResponse, CasesConfigureRequest, ConnectorTypes, + SECURITY_SOLUTION_OWNER, } from '../../../common'; import { CaseConfigure, CaseConnectorMapping } from './types'; @@ -116,6 +117,7 @@ export const actionTypesMock: ActionTypeConnector[] = [ ]; export const caseConfigurationResposeMock: CasesConfigureResponse = { + id: '123', created_at: '2020-04-06T13:03:18.657Z', created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, connector: { @@ -129,6 +131,7 @@ export const caseConfigurationResposeMock: CasesConfigureResponse = { mappings: [], updated_at: '2020-04-06T14:03:18.657Z', updated_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, + owner: SECURITY_SOLUTION_OWNER, version: 'WzHJ12', }; @@ -139,10 +142,12 @@ export const caseConfigurationMock: CasesConfigureRequest = { type: ConnectorTypes.jira, fields: null, }, + owner: SECURITY_SOLUTION_OWNER, closure_type: 'close-by-user', }; export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { + id: '123', createdAt: '2020-04-06T13:03:18.657Z', createdBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, connector: { @@ -157,4 +162,5 @@ export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { updatedAt: '2020-04-06T14:03:18.657Z', updatedBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, version: 'WzHJ12', + owner: SECURITY_SOLUTION_OWNER, }; diff --git a/x-pack/plugins/cases/public/containers/configure/types.ts b/x-pack/plugins/cases/public/containers/configure/types.ts index b021ae2163fa2..61c81a8ce97c1 100644 --- a/x-pack/plugins/cases/public/containers/configure/types.ts +++ b/x-pack/plugins/cases/public/containers/configure/types.ts @@ -34,6 +34,7 @@ export interface CaseConnectorMapping { } export interface CaseConfigure { + id: string; closureType: ClosureType; connector: CasesConfigure['connector']; createdAt: string; @@ -43,4 +44,5 @@ export interface CaseConfigure { updatedAt: string; updatedBy: ElasticUser; version: string; + owner: string; } diff --git a/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx index 968afcc6ecfb3..d8d552ceb8b7a 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { initialState, @@ -15,6 +16,7 @@ import { import { mappings, caseConfigurationCamelCaseResponseMock } from './mock'; import * as api from './api'; import { ConnectorTypes } from '../../../common'; +import { TestProviders } from '../../common/mock'; const mockErrorToast = jest.fn(); const mockSuccessToast = jest.fn(); @@ -49,8 +51,11 @@ describe('useConfigure', () => { test('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); expect(result.current).toEqual({ @@ -67,8 +72,11 @@ describe('useConfigure', () => { test('fetch case configuration', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -90,6 +98,7 @@ describe('useConfigure', () => { setCurrentConfiguration: result.current.setCurrentConfiguration, setMappings: result.current.setMappings, version: caseConfigurationCamelCaseResponseMock.version, + id: caseConfigurationCamelCaseResponseMock.id, }); }); }); @@ -98,8 +107,11 @@ describe('useConfigure', () => { const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -110,8 +122,11 @@ describe('useConfigure', () => { test('correctly sets mappings', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -123,8 +138,11 @@ describe('useConfigure', () => { test('set isLoading to true when fetching case configuration', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -136,8 +154,11 @@ describe('useConfigure', () => { test('persist case configuration', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -165,8 +186,11 @@ describe('useConfigure', () => { ); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -191,8 +215,11 @@ describe('useConfigure', () => { ); await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -220,8 +247,11 @@ describe('useConfigure', () => { ); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -244,8 +274,11 @@ describe('useConfigure', () => { ); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -265,8 +298,11 @@ describe('useConfigure', () => { }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); @@ -292,6 +328,7 @@ describe('useConfigure', () => { Promise.resolve({ ...caseConfigurationCamelCaseResponseMock, version: '', + id: '', }) ); const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); @@ -300,8 +337,11 @@ describe('useConfigure', () => { }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCaseConfigure() + const { result, waitForNextUpdate } = renderHook( + () => useCaseConfigure(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); diff --git a/x-pack/plugins/cases/public/containers/configure/use_configure.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.tsx index c4b3db5956cd7..d02a22bde408c 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_configure.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_configure.tsx @@ -12,6 +12,7 @@ import * as i18n from './translations'; import { ClosureType, CaseConfigure, CaseConnector, CaseConnectorMapping } from './types'; import { ConnectorTypes } from '../../../common'; import { useToasts } from '../../common/lib/kibana'; +import { useOwnerContext } from '../../components/owner_context/use_owner_context'; export type ConnectorConfiguration = { connector: CaseConnector } & { closureType: CaseConfigure['closureType']; @@ -24,6 +25,7 @@ export interface State extends ConnectorConfiguration { mappings: CaseConnectorMapping[]; persistLoading: boolean; version: string; + id: string; } export type Action = | { @@ -50,6 +52,10 @@ export type Action = type: 'setVersion'; payload: string; } + | { + type: 'setID'; + payload: string; + } | { type: 'setClosureType'; closureType: ClosureType; @@ -81,6 +87,11 @@ export const configureCasesReducer = (state: State, action: Action) => { ...state, version: action.payload, }; + case 'setID': + return { + ...state, + id: action.payload, + }; case 'setCurrentConfiguration': { return { ...state, @@ -141,9 +152,11 @@ export const initialState: State = { mappings: [], persistLoading: false, version: '', + id: '', }; export const useCaseConfigure = (): ReturnUseCaseConfigure => { + const owner = useOwnerContext(); const [state, dispatch] = useReducer(configureCasesReducer, initialState); const toasts = useToasts(); const setCurrentConfiguration = useCallback((configuration: ConnectorConfiguration) => { @@ -202,6 +215,13 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { }); }, []); + const setID = useCallback((id: string) => { + dispatch({ + payload: id, + type: 'setID', + }); + }, []); + const isCancelledRefetchRef = useRef(false); const abortCtrlRefetchRef = useRef(new AbortController()); @@ -215,7 +235,10 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { abortCtrlRefetchRef.current = new AbortController(); setLoading(true); - const res = await getCaseConfigure({ signal: abortCtrlRefetchRef.current.signal }); + const res = await getCaseConfigure({ + signal: abortCtrlRefetchRef.current.signal, + owner, + }); if (!isCancelledRefetchRef.current) { if (res != null) { @@ -224,6 +247,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { setClosureType(res.closureType); } setVersion(res.version); + setID(res.id); setMappings(res.mappings); if (!state.firstLoad) { @@ -274,8 +298,13 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { const res = state.version.length === 0 - ? await postCaseConfigure(connectorObj, abortCtrlPersistRef.current.signal) + ? await postCaseConfigure( + // The first owner will be used for case creation + { ...connectorObj, owner: owner[0] }, + abortCtrlPersistRef.current.signal + ) : await patchCaseConfigure( + state.id, { ...connectorObj, version: state.version, @@ -288,6 +317,7 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { setClosureType(res.closureType); } setVersion(res.version); + setID(res.id); setMappings(res.mappings); if (setCurrentConfiguration != null) { setCurrentConfiguration({ @@ -321,14 +351,17 @@ export const useCaseConfigure = (): ReturnUseCaseConfigure => { } }, [ - setClosureType, - setConnector, - setCurrentConfiguration, - setMappings, setPersistLoading, - setVersion, - state.currentConfiguration.connector, state.version, + state.id, + state.currentConfiguration.connector, + owner, + setConnector, + setClosureType, + setVersion, + setID, + setMappings, + setCurrentConfiguration, toasts, ] ); diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 1e7cec29de56b..72fee3c602c4e 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -19,6 +19,7 @@ import { CommentResponse, CommentType, ConnectorTypes, + SECURITY_SOLUTION_OWNER, UserAction, UserActionField, } from '../../common'; @@ -47,6 +48,7 @@ export const basicComment: Comment = { id: basicCommentId, createdAt: basicCreatedAt, createdBy: elasticUser, + owner: SECURITY_SOLUTION_OWNER, pushedAt: null, pushedBy: null, updatedAt: null, @@ -62,6 +64,7 @@ export const alertComment: Comment = { id: 'alert-comment-id', createdAt: basicCreatedAt, createdBy: elasticUser, + owner: SECURITY_SOLUTION_OWNER, pushedAt: null, pushedBy: null, rule: { @@ -75,6 +78,7 @@ export const alertComment: Comment = { export const basicCase: Case = { type: CaseType.individual, + owner: SECURITY_SOLUTION_OWNER, closedAt: null, closedBy: null, id: basicCaseId, @@ -105,6 +109,7 @@ export const basicCase: Case = { export const collectionCase: Case = { type: CaseType.collection, + owner: SECURITY_SOLUTION_OWNER, closedAt: null, closedBy: null, id: 'collection-id', @@ -181,6 +186,7 @@ const basicAction = { newValue: 'what a cool value', caseId: basicCaseId, commentId: null, + owner: SECURITY_SOLUTION_OWNER, }; export const cases: Case[] = [ @@ -230,6 +236,7 @@ export const basicCommentSnake: CommentResponse = { id: basicCommentId, created_at: basicCreatedAt, created_by: elasticUserSnake, + owner: SECURITY_SOLUTION_OWNER, pushed_at: null, pushed_by: null, updated_at: null, @@ -254,6 +261,7 @@ export const basicCaseSnake: CaseResponse = { external_service: null, updated_at: basicUpdatedAt, updated_by: elasticUserSnake, + owner: SECURITY_SOLUTION_OWNER, } as CaseResponse; export const casesStatusSnake: CasesStatusResponse = { @@ -311,6 +319,7 @@ const basicActionSnake = { new_value: 'what a cool value', case_id: basicCaseId, comment_id: null, + owner: SECURITY_SOLUTION_OWNER, }; export const getUserActionSnake = (af: UserActionField, a: UserAction) => ({ ...basicActionSnake, diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx index b07fec4984eb1..b3a6932c6971c 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -5,8 +5,9 @@ * 2.0. */ +import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; -import { CaseStatuses } from '../../common'; +import { CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../common'; import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS, @@ -17,6 +18,7 @@ import { import { UpdateKey } from './types'; import { allCases, basicCase } from './mock'; import * as api from './api'; +import { TestProviders } from '../common/mock'; jest.mock('./api'); jest.mock('../common/lib/kibana'); @@ -30,7 +32,10 @@ describe('useGetCases', () => { it('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetCases()); + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); + await waitForNextUpdate(); expect(result.current).toEqual({ data: initialData, @@ -51,11 +56,13 @@ describe('useGetCases', () => { it('calls getCases with correct arguments', async () => { const spyOnGetCases = jest.spyOn(api, 'getCases'); await act(async () => { - const { waitForNextUpdate } = renderHook(() => useGetCases()); + const { waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); expect(spyOnGetCases).toBeCalledWith({ - filterOptions: DEFAULT_FILTER_OPTIONS, + filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, }); @@ -64,7 +71,9 @@ describe('useGetCases', () => { it('fetch cases', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetCases()); + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); expect(result.current).toEqual({ @@ -82,6 +91,7 @@ describe('useGetCases', () => { }); }); }); + it('dispatch update case property', async () => { const spyOnPatchCase = jest.spyOn(api, 'patchCase'); await act(async () => { @@ -92,7 +102,9 @@ describe('useGetCases', () => { refetchCasesStatus: jest.fn(), version: '99999', }; - const { result, waitForNextUpdate } = renderHook(() => useGetCases()); + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); result.current.dispatchUpdateCaseProperty(updateCase); @@ -109,7 +121,9 @@ describe('useGetCases', () => { it('refetch cases', async () => { const spyOnGetCases = jest.spyOn(api, 'getCases'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetCases()); + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); result.current.refetchCases(); @@ -119,7 +133,9 @@ describe('useGetCases', () => { it('set isLoading to true when refetching case', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetCases()); + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); result.current.refetchCases(); @@ -135,7 +151,9 @@ describe('useGetCases', () => { }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetCases()); + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); @@ -154,6 +172,7 @@ describe('useGetCases', () => { }); }); }); + it('set filters', async () => { await act(async () => { const spyOnGetCases = jest.spyOn(api, 'getCases'); @@ -162,40 +181,61 @@ describe('useGetCases', () => { tags: ['new'], status: CaseStatuses.closed, }; - const { result, waitForNextUpdate } = renderHook(() => useGetCases()); + + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); + await waitForNextUpdate(); await waitForNextUpdate(); result.current.setFilters(newFilters); await waitForNextUpdate(); + expect(spyOnGetCases.mock.calls[1][0]).toEqual({ - filterOptions: { ...DEFAULT_FILTER_OPTIONS, ...newFilters }, + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + ...newFilters, + owner: [SECURITY_SOLUTION_OWNER], + }, queryParams: DEFAULT_QUERY_PARAMS, signal: abortCtrl.signal, }); }); }); + it('set query params', async () => { await act(async () => { const spyOnGetCases = jest.spyOn(api, 'getCases'); const newQueryParams = { page: 2, }; - const { result, waitForNextUpdate } = renderHook(() => useGetCases()); + + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); + await waitForNextUpdate(); await waitForNextUpdate(); result.current.setQueryParams(newQueryParams); await waitForNextUpdate(); + expect(spyOnGetCases.mock.calls[1][0]).toEqual({ - filterOptions: DEFAULT_FILTER_OPTIONS, - queryParams: { ...DEFAULT_QUERY_PARAMS, ...newQueryParams }, + filterOptions: { ...DEFAULT_FILTER_OPTIONS, owner: [SECURITY_SOLUTION_OWNER] }, + queryParams: { + ...DEFAULT_QUERY_PARAMS, + ...newQueryParams, + }, signal: abortCtrl.signal, }); }); }); + it('set selected cases', async () => { await act(async () => { const selectedCases = [basicCase]; - const { result, waitForNextUpdate } = renderHook(() => useGetCases()); + const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); result.current.setSelectedCases(selectedCases); diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx index ec1abd6214926..b3aa374f5418e 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -19,6 +19,7 @@ import { import { useToasts } from '../common/lib/kibana'; import * as i18n from './translations'; import { getCases, patchCase } from './api'; +import { useOwnerContext } from '../components/owner_context/use_owner_context'; export interface UseGetCasesState { data: AllCases; @@ -139,12 +140,19 @@ export interface UseGetCases extends UseGetCasesState { const empty = {}; export const useGetCases = ( - initialQueryParams: Partial = empty, - initialFilterOptions: Partial = empty + params: { + initialQueryParams?: Partial; + initialFilterOptions?: Partial; + } = {} ): UseGetCases => { + const owner = useOwnerContext(); + const { initialQueryParams = empty, initialFilterOptions = empty } = params; const [state, dispatch] = useReducer(dataFetchReducer, { data: initialData, - filterOptions: { ...DEFAULT_FILTER_OPTIONS, ...initialFilterOptions }, + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + ...initialFilterOptions, + }, isError: false, loading: [], queryParams: { ...DEFAULT_QUERY_PARAMS, ...initialQueryParams }, @@ -177,7 +185,7 @@ export const useGetCases = ( dispatch({ type: 'FETCH_INIT', payload: 'cases' }); const response = await getCases({ - filterOptions, + filterOptions: { ...filterOptions, owner }, queryParams, signal: abortCtrlFetchCases.current.signal, }); @@ -200,7 +208,7 @@ export const useGetCases = ( } } }, - [toasts] + [owner, toasts] ); const dispatchUpdateCaseProperty = useCallback( diff --git a/x-pack/plugins/cases/public/containers/use_get_cases_status.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases_status.test.tsx index f795d5cc60e71..b9047fdafee61 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases_status.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases_status.test.tsx @@ -5,10 +5,13 @@ * 2.0. */ +import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { useGetCasesStatus, UseGetCasesStatus } from './use_get_cases_status'; import { casesStatus } from './mock'; import * as api from './api'; +import { TestProviders } from '../common/mock'; +import { SECURITY_SOLUTION_OWNER } from '../../common'; jest.mock('./api'); jest.mock('../common/lib/kibana'); @@ -22,8 +25,11 @@ describe('useGetCasesStatus', () => { it('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetCasesStatus() + const { result, waitForNextUpdate } = renderHook( + () => useGetCasesStatus(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); expect(result.current).toEqual({ @@ -40,19 +46,25 @@ describe('useGetCasesStatus', () => { it('calls getCasesStatus api', async () => { const spyOnGetCasesStatus = jest.spyOn(api, 'getCasesStatus'); await act(async () => { - const { waitForNextUpdate } = renderHook(() => - useGetCasesStatus() + const { waitForNextUpdate } = renderHook( + () => useGetCasesStatus(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); - expect(spyOnGetCasesStatus).toBeCalledWith(abortCtrl.signal); + expect(spyOnGetCasesStatus).toBeCalledWith(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); }); }); it('fetch reporters', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetCasesStatus() + const { result, waitForNextUpdate } = renderHook( + () => useGetCasesStatus(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -74,8 +86,11 @@ describe('useGetCasesStatus', () => { }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetCasesStatus() + const { result, waitForNextUpdate } = renderHook( + () => useGetCasesStatus(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); diff --git a/x-pack/plugins/cases/public/containers/use_get_cases_status.tsx b/x-pack/plugins/cases/public/containers/use_get_cases_status.tsx index c3244bb38f151..909bc42345759 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases_status.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases_status.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useState, useRef } from 'react'; +import { useOwnerContext } from '../components/owner_context/use_owner_context'; import { getCasesStatus } from './api'; import * as i18n from './translations'; import { CasesStatus } from './types'; @@ -30,6 +31,7 @@ export interface UseGetCasesStatus extends CasesStatusState { } export const useGetCasesStatus = (): UseGetCasesStatus => { + const owner = useOwnerContext(); const [casesStatusState, setCasesStatusState] = useState(initialData); const toasts = useToasts(); const isCancelledRef = useRef(false); @@ -45,7 +47,7 @@ export const useGetCasesStatus = (): UseGetCasesStatus => { isLoading: true, }); - const response = await getCasesStatus(abortCtrlRef.current.signal); + const response = await getCasesStatus(abortCtrlRef.current.signal, owner); if (!isCancelledRef.current) { setCasesStatusState({ diff --git a/x-pack/plugins/cases/public/containers/use_get_reporters.test.tsx b/x-pack/plugins/cases/public/containers/use_get_reporters.test.tsx index 8345ddf107872..692c5237f58bf 100644 --- a/x-pack/plugins/cases/public/containers/use_get_reporters.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_reporters.test.tsx @@ -5,10 +5,13 @@ * 2.0. */ +import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { useGetReporters, UseGetReporters } from './use_get_reporters'; import { reporters, respReporters } from './mock'; import * as api from './api'; +import { TestProviders } from '../common/mock'; +import { SECURITY_SOLUTION_OWNER } from '../../common'; jest.mock('./api'); jest.mock('../common/lib/kibana'); @@ -22,8 +25,11 @@ describe('useGetReporters', () => { it('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetReporters() + const { result, waitForNextUpdate } = renderHook( + () => useGetReporters(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); expect(result.current).toEqual({ @@ -39,17 +45,22 @@ describe('useGetReporters', () => { it('calls getReporters api', async () => { const spyOnGetReporters = jest.spyOn(api, 'getReporters'); await act(async () => { - const { waitForNextUpdate } = renderHook(() => useGetReporters()); + const { waitForNextUpdate } = renderHook(() => useGetReporters(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); - expect(spyOnGetReporters).toBeCalledWith(abortCtrl.signal); + expect(spyOnGetReporters).toBeCalledWith(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); }); }); it('fetch reporters', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetReporters() + const { result, waitForNextUpdate } = renderHook( + () => useGetReporters(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -66,8 +77,11 @@ describe('useGetReporters', () => { it('refetch reporters', async () => { const spyOnGetReporters = jest.spyOn(api, 'getReporters'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetReporters() + const { result, waitForNextUpdate } = renderHook( + () => useGetReporters(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -83,8 +97,11 @@ describe('useGetReporters', () => { }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useGetReporters() + const { result, waitForNextUpdate } = renderHook( + () => useGetReporters(), + { + wrapper: ({ children }) => {children}, + } ); await waitForNextUpdate(); await waitForNextUpdate(); diff --git a/x-pack/plugins/cases/public/containers/use_get_reporters.tsx b/x-pack/plugins/cases/public/containers/use_get_reporters.tsx index a9d28de33cb41..b3c2eff2c8e01 100644 --- a/x-pack/plugins/cases/public/containers/use_get_reporters.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_reporters.tsx @@ -12,6 +12,7 @@ import { User } from '../../common'; import { getReporters } from './api'; import * as i18n from './translations'; import { useToasts } from '../common/lib/kibana'; +import { useOwnerContext } from '../components/owner_context/use_owner_context'; interface ReportersState { reporters: string[]; @@ -32,6 +33,7 @@ export interface UseGetReporters extends ReportersState { } export const useGetReporters = (): UseGetReporters => { + const owner = useOwnerContext(); const [reportersState, setReporterState] = useState(initialData); const toasts = useToasts(); @@ -48,7 +50,7 @@ export const useGetReporters = (): UseGetReporters => { isLoading: true, }); - const response = await getReporters(abortCtrlRef.current.signal); + const response = await getReporters(abortCtrlRef.current.signal, owner); const myReporters = response .map((r) => (r.full_name == null || isEmpty(r.full_name) ? r.username ?? '' : r.full_name)) .filter((u) => !isEmpty(u)); @@ -78,7 +80,7 @@ export const useGetReporters = (): UseGetReporters => { }); } } - }, [reportersState, toasts]); + }, [owner, reportersState, toasts]); useEffect(() => { fetchReporters(); diff --git a/x-pack/plugins/cases/public/containers/use_get_tags.test.tsx b/x-pack/plugins/cases/public/containers/use_get_tags.test.tsx index 3fecfb51b958c..60d368aca0a04 100644 --- a/x-pack/plugins/cases/public/containers/use_get_tags.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_tags.test.tsx @@ -5,10 +5,13 @@ * 2.0. */ +import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { useGetTags, UseGetTags } from './use_get_tags'; import { tags } from './mock'; import * as api from './api'; +import { TestProviders } from '../common/mock'; +import { SECURITY_SOLUTION_OWNER } from '../../common'; jest.mock('./api'); jest.mock('../common/lib/kibana'); @@ -22,7 +25,9 @@ describe('useGetTags', () => { it('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetTags()); + const { result, waitForNextUpdate } = renderHook(() => useGetTags(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); expect(result.current).toEqual({ tags: [], @@ -36,16 +41,20 @@ describe('useGetTags', () => { it('calls getTags api', async () => { const spyOnGetTags = jest.spyOn(api, 'getTags'); await act(async () => { - const { waitForNextUpdate } = renderHook(() => useGetTags()); + const { waitForNextUpdate } = renderHook(() => useGetTags(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); - expect(spyOnGetTags).toBeCalledWith(abortCtrl.signal); + expect(spyOnGetTags).toBeCalledWith(abortCtrl.signal, [SECURITY_SOLUTION_OWNER]); }); }); it('fetch tags', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetTags()); + const { result, waitForNextUpdate } = renderHook(() => useGetTags(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); expect(result.current).toEqual({ @@ -60,7 +69,9 @@ describe('useGetTags', () => { it('refetch tags', async () => { const spyOnGetTags = jest.spyOn(api, 'getTags'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetTags()); + const { result, waitForNextUpdate } = renderHook(() => useGetTags(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); result.current.fetchTags(); @@ -75,7 +86,9 @@ describe('useGetTags', () => { }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetTags()); + const { result, waitForNextUpdate } = renderHook(() => useGetTags(), { + wrapper: ({ children }) => {children}, + }); await waitForNextUpdate(); await waitForNextUpdate(); diff --git a/x-pack/plugins/cases/public/containers/use_get_tags.tsx b/x-pack/plugins/cases/public/containers/use_get_tags.tsx index 4368b025baa38..362e7ebf8fbf3 100644 --- a/x-pack/plugins/cases/public/containers/use_get_tags.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_tags.tsx @@ -7,6 +7,7 @@ import { useEffect, useReducer, useRef, useCallback } from 'react'; import { useToasts } from '../common/lib/kibana'; +import { useOwnerContext } from '../components/owner_context/use_owner_context'; import { getTags } from './api'; import * as i18n from './translations'; @@ -52,6 +53,7 @@ const dataFetchReducer = (state: TagsState, action: Action): TagsState => { const initialData: string[] = []; export const useGetTags = (): UseGetTags => { + const owner = useOwnerContext(); const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: true, isError: false, @@ -68,7 +70,7 @@ export const useGetTags = (): UseGetTags => { abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT' }); - const response = await getTags(abortCtrlRef.current.signal); + const response = await getTags(abortCtrlRef.current.signal, owner); if (!isCancelledRef.current) { dispatch({ type: 'FETCH_SUCCESS', payload: response }); diff --git a/x-pack/plugins/cases/public/containers/use_post_case.test.tsx b/x-pack/plugins/cases/public/containers/use_post_case.test.tsx index f7f7f1419c713..d2b638b4c846f 100644 --- a/x-pack/plugins/cases/public/containers/use_post_case.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_post_case.test.tsx @@ -8,7 +8,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { usePostCase, UsePostCase } from './use_post_case'; import * as api from './api'; -import { ConnectorTypes } from '../../common'; +import { ConnectorTypes, SECURITY_SOLUTION_OWNER } from '../../common'; import { basicCasePost } from './mock'; jest.mock('./api'); @@ -29,6 +29,7 @@ describe('usePostCase', () => { settings: { syncAlerts: true, }, + owner: SECURITY_SOLUTION_OWNER, }; beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/cases/public/containers/use_post_comment.test.tsx b/x-pack/plugins/cases/public/containers/use_post_comment.test.tsx index 5b927f55c9e91..8a86d9becdfde 100644 --- a/x-pack/plugins/cases/public/containers/use_post_comment.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_post_comment.test.tsx @@ -7,7 +7,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { CommentType } from '../../common'; +import { CommentType, SECURITY_SOLUTION_OWNER } from '../../common'; import { usePostComment, UsePostComment } from './use_post_comment'; import { basicCaseId, basicSubCaseId } from './mock'; import * as api from './api'; @@ -20,6 +20,7 @@ describe('usePostComment', () => { const samplePost = { comment: 'a comment', type: CommentType.user as const, + owner: SECURITY_SOLUTION_OWNER, }; const updateCaseCallback = jest.fn(); beforeEach(() => { diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts index 5ef30aa800f90..de67b1cfbd6fa 100644 --- a/x-pack/plugins/cases/public/containers/utils.ts +++ b/x-pack/plugins/cases/public/containers/utils.ts @@ -22,6 +22,8 @@ import { CasesStatusResponseRt, CasesStatusResponse, throwErrors, + CasesConfigurationsResponse, + CaseConfigurationsResponseRt, CasesConfigureResponse, CaseConfigureResponseRt, CaseUserActionsResponse, @@ -92,6 +94,13 @@ export const decodeCasesResponse = (respCase?: CasesResponse) => export const decodeCasesFindResponse = (respCases?: CasesFindResponse) => pipe(CasesFindResponseRt.decode(respCases), fold(throwErrors(createToasterPlainError), identity)); +export const decodeCaseConfigurationsResponse = (respCase?: CasesConfigurationsResponse) => { + return pipe( + CaseConfigurationsResponseRt.decode(respCase), + fold(throwErrors(createToasterPlainError), identity) + ); +}; + export const decodeCaseConfigureResponse = (respCase?: CasesConfigureResponse) => pipe( CaseConfigureResponseRt.decode(respCase), diff --git a/x-pack/plugins/cases/public/methods/get_all_cases_selector_modal.tsx b/x-pack/plugins/cases/public/methods/get_all_cases_selector_modal.tsx index b6caae39c284a..dbb466129c60b 100644 --- a/x-pack/plugins/cases/public/methods/get_all_cases_selector_modal.tsx +++ b/x-pack/plugins/cases/public/methods/get_all_cases_selector_modal.tsx @@ -8,10 +8,14 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; import { AllCasesSelectorModalProps } from '../components/all_cases/selector_modal'; +import { OwnerProvider } from '../components/owner_context'; +import { Owner } from '../types'; const AllCasesSelectorModalLazy = lazy(() => import('../components/all_cases/selector_modal')); -export const getAllCasesSelectorModalLazy = (props: AllCasesSelectorModalProps) => ( - }> - - +export const getAllCasesSelectorModalLazy = (props: AllCasesSelectorModalProps & Owner) => ( + + }> + + + ); diff --git a/x-pack/plugins/cases/public/types.ts b/x-pack/plugins/cases/public/types.ts index 269d1773b3404..2193832492aa2 100644 --- a/x-pack/plugins/cases/public/types.ts +++ b/x-pack/plugins/cases/public/types.ts @@ -39,6 +39,10 @@ export type StartServices = CoreStart & security: SecurityPluginSetup; }; +export interface Owner { + owner: string[]; +} + export interface CasesUiStart { getAllCases: (props: AllCasesProps) => ReactElement; getAllCasesSelectorModal: ( diff --git a/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap b/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap new file mode 100644 index 0000000000000..7f5b8406b89f3 --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap @@ -0,0 +1,1765 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createCase" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to create cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createCase" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "creation", + ], + }, + "message": "Failed attempt to create a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createCase" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User is creating cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createCase" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "message": "User is creating a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createComment" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_create", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-comments", + }, + }, + "message": "Failed attempt to create cases-comments [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createComment" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_create", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "creation", + ], + }, + "message": "Failed attempt to create a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createComment" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-comments", + }, + }, + "message": "User is creating cases-comments [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createComment" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "message": "User is creating a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createConfiguration" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_configuration_create", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-configure", + }, + }, + "message": "Failed attempt to create cases-configure [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createConfiguration" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_configuration_create", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "creation", + ], + }, + "message": "Failed attempt to create a case configuration as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createConfiguration" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_configuration_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-configure", + }, + }, + "message": "User is creating cases-configure [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "createConfiguration" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_configuration_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "message": "User is creating a case configuration as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteAllComments" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_delete_all", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "deletion", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-comments", + }, + }, + "message": "Failed attempt to delete cases-comments [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteAllComments" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_delete_all", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "deletion", + ], + }, + "message": "Failed attempt to delete a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteAllComments" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_delete_all", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "deletion", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-comments", + }, + }, + "message": "User is deleting cases-comments [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteAllComments" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_delete_all", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "deletion", + ], + }, + "message": "User is deleting a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteCase" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_delete", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "deletion", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to delete cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteCase" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_delete", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "deletion", + ], + }, + "message": "Failed attempt to delete a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteCase" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_delete", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "deletion", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User is deleting cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteCase" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_delete", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "deletion", + ], + }, + "message": "User is deleting a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteComment" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_delete", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "deletion", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-comments", + }, + }, + "message": "Failed attempt to delete cases-comments [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteComment" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_delete", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "deletion", + ], + }, + "message": "Failed attempt to delete a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteComment" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_delete", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "deletion", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-comments", + }, + }, + "message": "User is deleting cases-comments [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "deleteComment" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_delete", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "deletion", + ], + }, + "message": "User is deleting a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findCases" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to access cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findCases" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a cases as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findCases" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findCases" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a cases as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findComments" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_find", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-comments", + }, + }, + "message": "Failed attempt to access cases-comments [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findComments" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_find", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findComments" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-comments", + }, + }, + "message": "User has accessed cases-comments [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findComments" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findConfigurations" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_configuration_find", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-configure", + }, + }, + "message": "Failed attempt to access cases-configure [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findConfigurations" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_configuration_find", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a case configurations as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findConfigurations" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_configuration_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-configure", + }, + }, + "message": "User has accessed cases-configure [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "findConfigurations" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_configuration_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a case configurations as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getAllComments" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_get_all", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-comments", + }, + }, + "message": "Failed attempt to access cases-comments [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getAllComments" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_get_all", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getAllComments" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_get_all", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-comments", + }, + }, + "message": "User has accessed cases-comments [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getAllComments" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_get_all", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCase" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to access cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCase" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCase" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCase" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCaseIDsByAlertID" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_ids_by_alert_id_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-comments", + }, + }, + "message": "Failed attempt to access cases-comments [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCaseIDsByAlertID" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_ids_by_alert_id_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a cases as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCaseIDsByAlertID" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_ids_by_alert_id_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-comments", + }, + }, + "message": "User has accessed cases-comments [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCaseIDsByAlertID" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_ids_by_alert_id_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a cases as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCaseStatuses" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_find_statuses", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to access cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCaseStatuses" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_find_statuses", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a cases as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCaseStatuses" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_find_statuses", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCaseStatuses" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_find_statuses", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a cases as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getComment" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-comments", + }, + }, + "message": "Failed attempt to access cases-comments [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getComment" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getComment" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-comments", + }, + }, + "message": "User has accessed cases-comments [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getComment" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getReporters" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_reporters_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to access cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getReporters" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_reporters_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getReporters" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_reporters_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getReporters" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_reporters_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getTags" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_tags_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to access cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getTags" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_tags_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getTags" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_tags_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getTags" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_tags_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActions" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_user_actions_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-user-actions", + }, + }, + "message": "Failed attempt to access cases-user-actions [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActions" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_user_actions_get", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a user actions as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActions" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_user_actions_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-user-actions", + }, + }, + "message": "User has accessed cases-user-actions [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getUserActions" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_user_actions_get", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a user actions as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "pushCase" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_push", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to update cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "pushCase" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_push", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "change", + ], + }, + "message": "Failed attempt to update a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "pushCase" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_push", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User is updating cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "pushCase" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_push", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "change", + ], + }, + "message": "User is updating a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateCase" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_update", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to update cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateCase" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_update", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "change", + ], + }, + "message": "Failed attempt to update a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateCase" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_update", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User is updating cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateCase" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_update", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "change", + ], + }, + "message": "User is updating a case as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateComment" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_update", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-comments", + }, + }, + "message": "Failed attempt to update cases-comments [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateComment" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_comment_update", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "change", + ], + }, + "message": "Failed attempt to update a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateComment" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_update", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-comments", + }, + }, + "message": "User is updating cases-comments [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateComment" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_comment_update", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "change", + ], + }, + "message": "User is updating a comments as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateConfiguration" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_configuration_update", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases-configure", + }, + }, + "message": "Failed attempt to update cases-configure [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateConfiguration" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_configuration_update", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "change", + ], + }, + "message": "Failed attempt to update a case configuration as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateConfiguration" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_configuration_update", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "change", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases-configure", + }, + }, + "message": "User is updating cases-configure [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "updateConfiguration" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_configuration_update", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "change", + ], + }, + "message": "User is updating a case configuration as any owners", +} +`; diff --git a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts new file mode 100644 index 0000000000000..d54b5164b10b9 --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { AuditLogger } from '../../../../plugins/security/server'; +import { Operations } from '.'; +import { AuthorizationAuditLogger } from './audit_logger'; +import { ReadOperations } from './types'; + +describe('audit_logger', () => { + it('creates a failure message without any owners', () => { + expect( + AuthorizationAuditLogger.createFailureMessage({ + owners: [], + operation: Operations.createCase, + }) + ).toBe('Unauthorized to create case of any owner'); + }); + + it('creates a failure message with owners', () => { + expect( + AuthorizationAuditLogger.createFailureMessage({ + owners: ['a', 'b'], + operation: Operations.createCase, + }) + ).toBe('Unauthorized to create case with owners: "a, b"'); + }); + + describe('log function', () => { + const mockLogger: jest.Mocked = { + log: jest.fn(), + }; + + let logger: AuthorizationAuditLogger; + + beforeEach(() => { + mockLogger.log.mockReset(); + logger = new AuthorizationAuditLogger(mockLogger); + }); + + it('does not throw an error when the underlying audit logger is undefined', () => { + const authLogger = new AuthorizationAuditLogger(); + jest.spyOn(authLogger, 'log'); + + expect(() => { + authLogger.log({ + operation: Operations.createCase, + entity: { + owner: 'a', + id: '1', + }, + }); + }).not.toThrow(); + + expect(authLogger.log).toHaveBeenCalledTimes(1); + }); + + it('logs a message with a saved object ID in the message field', () => { + logger.log({ + operation: Operations.createCase, + entity: { + owner: 'a', + id: '1', + }, + }); + expect(mockLogger.log.mock.calls[0][0]?.message).toContain('[id=1]'); + }); + + it('creates the owner part of the message when no owners are specified', () => { + logger.log({ + operation: Operations.createCase, + }); + + expect(mockLogger.log.mock.calls[0][0]?.message).toContain('as any owners'); + }); + + it('creates the owner part of the message when an owner is specified', () => { + logger.log({ + operation: Operations.createCase, + entity: { + owner: 'a', + id: '1', + }, + }); + + expect(mockLogger.log.mock.calls[0][0]?.message).toContain('as owner "a"'); + }); + + it('creates a failure message when passed an error', () => { + logger.log({ + operation: Operations.createCase, + entity: { + owner: 'a', + id: '1', + }, + error: new Error('error occurred'), + }); + + expect(mockLogger.log.mock.calls[0][0]?.message).toBe( + 'Failed attempt to create cases [id=1] as owner "a"' + ); + + expect(mockLogger.log.mock.calls[0][0]?.event?.outcome).toBe('failure'); + }); + + it('creates a write operation message', () => { + logger.log({ + operation: Operations.createCase, + entity: { + owner: 'a', + id: '1', + }, + }); + + expect(mockLogger.log.mock.calls[0][0]?.message).toBe( + 'User is creating cases [id=1] as owner "a"' + ); + + expect(mockLogger.log.mock.calls[0][0]?.event?.outcome).toBe('unknown'); + }); + + it('creates a read operation message', () => { + logger.log({ + operation: Operations.getCase, + entity: { + owner: 'a', + id: '1', + }, + }); + + expect(mockLogger.log.mock.calls[0][0]?.message).toBe( + 'User has accessed cases [id=1] as owner "a"' + ); + + expect(mockLogger.log.mock.calls[0][0]?.event?.outcome).toBe('success'); + }); + + describe('event structure', () => { + // I would have preferred to do these as match inline but that isn't supported because this is essentially a for loop + // for reference: https://github.com/facebook/jest/issues/9409#issuecomment-629272237 + + // This loops through all operation keys + it.each(Array.from(Object.keys(Operations)))( + `creates the correct audit event for operation: "%s" without an error or entity`, + (operationKey) => { + // forcing the cast here because using a string throws a type error + const key = operationKey as ReadOperations; + logger.log({ + operation: Operations[key], + }); + expect(mockLogger.log.mock.calls[0][0]).toMatchSnapshot(); + } + ); + + // This loops through all operation keys + it.each(Array.from(Object.keys(Operations)))( + `creates the correct audit event for operation: "%s" with an error but no entity`, + (operationKey) => { + // forcing the cast here because using a string throws a type error + const key = operationKey as ReadOperations; + logger.log({ + operation: Operations[key], + error: new Error('an error'), + }); + expect(mockLogger.log.mock.calls[0][0]).toMatchSnapshot(); + } + ); + + // This loops through all operation keys + it.each(Array.from(Object.keys(Operations)))( + `creates the correct audit event for operation: "%s" with an error and entity`, + (operationKey) => { + // forcing the cast here because using a string throws a type error + const key = operationKey as ReadOperations; + logger.log({ + operation: Operations[key], + entity: { + owner: 'awesome', + id: '1', + }, + error: new Error('an error'), + }); + expect(mockLogger.log.mock.calls[0][0]).toMatchSnapshot(); + } + ); + + // This loops through all operation keys + it.each(Array.from(Object.keys(Operations)))( + `creates the correct audit event for operation: "%s" without an error but with an entity`, + (operationKey) => { + // forcing the cast here because using a string throws a type error + const key = operationKey as ReadOperations; + logger.log({ + operation: Operations[key], + entity: { + owner: 'super', + id: '5', + }, + }); + expect(mockLogger.log.mock.calls[0][0]).toMatchSnapshot(); + } + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/authorization/audit_logger.ts b/x-pack/plugins/cases/server/authorization/audit_logger.ts new file mode 100644 index 0000000000000..a59dfaaa4dabe --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/audit_logger.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { EcsEventOutcome } from 'kibana/server'; +import { DATABASE_CATEGORY, ECS_OUTCOMES, isWriteOperation, OperationDetails } from '.'; +import { AuditEvent, AuditLogger } from '../../../security/server'; +import { OwnerEntity } from './types'; + +interface CreateAuditMsgParams { + operation: OperationDetails; + entity?: OwnerEntity; + error?: Error; +} + +/** + * Audit logger for authorization operations + */ +export class AuthorizationAuditLogger { + private readonly auditLogger?: AuditLogger; + + constructor(logger?: AuditLogger) { + this.auditLogger = logger; + } + + /** + * Creates an AuditEvent describing the state of a request. + */ + private static createAuditMsg({ operation, error, entity }: CreateAuditMsgParams): AuditEvent { + const doc = + entity !== undefined + ? `${operation.savedObjectType} [id=${entity.id}]` + : `a ${operation.docType}`; + + const ownerText = entity === undefined ? 'as any owners' : `as owner "${entity.owner}"`; + + let message: string; + let outcome: EcsEventOutcome; + + if (error) { + message = `Failed attempt to ${operation.verbs.present} ${doc} ${ownerText}`; + outcome = ECS_OUTCOMES.failure; + } else if (isWriteOperation(operation)) { + message = `User is ${operation.verbs.progressive} ${doc} ${ownerText}`; + outcome = ECS_OUTCOMES.unknown; + } else { + message = `User has ${operation.verbs.past} ${doc} ${ownerText}`; + outcome = ECS_OUTCOMES.success; + } + + return { + message, + event: { + action: operation.action, + category: DATABASE_CATEGORY, + type: [operation.ecsType], + outcome, + }, + ...(entity !== undefined && { + kibana: { + saved_object: { type: operation.savedObjectType, id: entity.id }, + }, + }), + ...(error !== undefined && { + error: { + code: error.name, + message: error.message, + }, + }), + }; + } + + /** + * Creates a message to be passed to an Error or Boom. + */ + public static createFailureMessage({ + owners, + operation, + }: { + owners: string[]; + operation: OperationDetails; + }) { + const ownerMsg = owners.length <= 0 ? 'of any owner' : `with owners: "${owners.join(', ')}"`; + /** + * This will take the form: + * `Unauthorized to create case with owners: "securitySolution, observability"` + * `Unauthorized to access cases of any owner` + */ + return `Unauthorized to ${operation.verbs.present} ${operation.docType} ${ownerMsg}`; + } + + /** + * Logs an audit event based on the status of an operation. + */ + public log(auditMsgParams: CreateAuditMsgParams) { + this.auditLogger?.log(AuthorizationAuditLogger.createAuditMsg(auditMsgParams)); + } +} diff --git a/x-pack/plugins/cases/server/authorization/authorization.test.ts b/x-pack/plugins/cases/server/authorization/authorization.test.ts new file mode 100644 index 0000000000000..e602de565f294 --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/authorization.test.ts @@ -0,0 +1,977 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { securityMock } from '../../../../plugins/security/server/mocks'; +import { httpServerMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { featuresPluginMock } from '../../../../plugins/features/server/mocks'; +import { Authorization, Operations } from '.'; +import { Space } from '../../../spaces/server'; +import { AuthorizationAuditLogger } from './audit_logger'; +import { KibanaRequest } from 'kibana/server'; +import { KibanaFeature } from '../../../../plugins/features/common'; +import { AuditLogger, SecurityPluginStart } from '../../../security/server'; +import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; + +describe('authorization', () => { + let request: KibanaRequest; + let mockLogger: jest.Mocked; + + beforeEach(() => { + request = httpServerMock.createKibanaRequest(); + mockLogger = { + log: jest.fn(), + }; + }); + + describe('create', () => { + let securityStart: jest.Mocked; + let featuresStart: jest.Mocked; + + beforeEach(() => { + securityStart = securityMock.createStart(); + featuresStart = featuresPluginMock.createStart(); + featuresStart.getKibanaFeatures.mockReturnValue(([ + { id: '1', cases: ['a'] }, + ] as unknown) as KibanaFeature[]); + }); + + it('creates an Authorization object', async () => { + expect.assertions(2); + + const getSpace = jest.fn(); + const authPromise = Authorization.create({ + request, + securityAuth: securityStart.authz, + getSpace, + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(), + logger: loggingSystemMock.createLogger(), + }); + + await expect(authPromise).resolves.toBeDefined(); + await expect(authPromise).resolves.not.toThrow(); + }); + + it('throws and error when a failure occurs', async () => { + expect.assertions(1); + + const getSpace = jest.fn(async () => { + throw new Error('space error'); + }); + + const authPromise = Authorization.create({ + request, + securityAuth: securityStart.authz, + getSpace, + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(), + logger: loggingSystemMock.createLogger(), + }); + + await expect(authPromise).rejects.toThrow(); + }); + }); + + describe('ensureAuthorized', () => { + const feature = { id: '1', cases: ['a'] }; + + let securityStart: ReturnType; + let featuresStart: jest.Mocked; + let auth: Authorization; + + beforeEach(async () => { + securityStart = securityMock.createStart(); + securityStart.authz.mode.useRbacForRequest.mockReturnValue(true); + securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue( + jest.fn(async () => ({ hasAllRequested: true })) + ); + + featuresStart = featuresPluginMock.createStart(); + featuresStart.getKibanaFeatures.mockReturnValue(([feature] as unknown) as KibanaFeature[]); + + auth = await Authorization.create({ + request, + securityAuth: securityStart.authz, + getSpace: jest.fn(), + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(mockLogger), + logger: loggingSystemMock.createLogger(), + }); + }); + + it('throws an error when the owner passed in is not included in the features when security is disabled', async () => { + expect.assertions(1); + securityStart.authz.mode.useRbacForRequest.mockReturnValue(false); + + try { + await auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'b' }], + operation: Operations.createCase, + }); + } catch (error) { + expect(error.message).toBe('Unauthorized to create case with owners: "b"'); + } + }); + + it('throws an error when the owner passed in is not included in the features when security undefined', async () => { + expect.assertions(1); + + auth = await Authorization.create({ + request, + getSpace: jest.fn(), + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(), + logger: loggingSystemMock.createLogger(), + }); + + try { + await auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'b' }], + operation: Operations.createCase, + }); + } catch (error) { + expect(error.message).toBe('Unauthorized to create case with owners: "b"'); + } + }); + + it('throws an error when the owner passed in is not included in the features when security is enabled', async () => { + expect.assertions(1); + + try { + await auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'b' }], + operation: Operations.createCase, + }); + } catch (error) { + expect(error.message).toBe('Unauthorized to create case with owners: "b"'); + } + }); + + it('logs the error thrown when the passed in owner is not one of the features', async () => { + expect.assertions(2); + + try { + await auth.ensureAuthorized({ + entities: [ + { id: '1', owner: 'b' }, + { id: '5', owner: 'z' }, + ], + operation: Operations.createCase, + }); + } catch (error) { + expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "error": Object { + "code": "Error", + "message": "Unauthorized to create case with owners: \\"b, z\\"", + }, + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to create cases [id=1] as owner \\"b\\"", + }, + ], + Array [ + Object { + "error": Object { + "code": "Error", + "message": "Unauthorized to create case with owners: \\"b, z\\"", + }, + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "Failed attempt to create cases [id=5] as owner \\"z\\"", + }, + ], + ] + `); + expect(error.message).toBe('Unauthorized to create case with owners: "b, z"'); + } + }); + + it('throws an error when the user does not have all the requested privileges', async () => { + expect.assertions(1); + + securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue( + jest.fn(async () => ({ hasAllRequested: false })) + ); + + try { + await auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'a' }], + operation: Operations.createCase, + }); + } catch (error) { + expect(error.message).toBe('Unauthorized to create case with owners: "a"'); + } + }); + + it('throws an error when owner does not exist because it was from a disabled plugin', async () => { + expect.assertions(1); + + auth = await Authorization.create({ + request, + securityAuth: securityStart.authz, + getSpace: jest.fn(async () => ({ disabledFeatures: [feature.id] } as Space)), + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(), + logger: loggingSystemMock.createLogger(), + }); + + try { + await auth.ensureAuthorized({ + entities: [{ id: '100', owner: feature.cases[0] }], + operation: Operations.createCase, + }); + } catch (error) { + expect(error.message).toBe( + `Unauthorized to create case with owners: "${feature.cases[0]}"` + ); + } + }); + + it('does not throw an error when the user has the privileges needed', async () => { + expect.assertions(1); + + featuresStart.getKibanaFeatures.mockReturnValue(([ + feature, + { id: '2', cases: ['other-owner'] }, + ] as unknown) as KibanaFeature[]); + + auth = await Authorization.create({ + request, + securityAuth: securityStart.authz, + getSpace: jest.fn(), + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(), + logger: loggingSystemMock.createLogger(), + }); + + await expect( + auth.ensureAuthorized({ + entities: [ + { id: '100', owner: feature.cases[0] }, + { id: '3', owner: 'other-owner' }, + ], + operation: Operations.createCase, + }) + ).resolves.not.toThrow(); + }); + + it('does not throw an error when the user has the privileges needed with a feature specifying multiple owners', async () => { + expect.assertions(1); + + featuresStart.getKibanaFeatures.mockReturnValue(([ + { id: '2', cases: ['a', 'other-owner'] }, + ] as unknown) as KibanaFeature[]); + + auth = await Authorization.create({ + request, + securityAuth: securityStart.authz, + getSpace: jest.fn(), + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(), + logger: loggingSystemMock.createLogger(), + }); + + await expect( + auth.ensureAuthorized({ + entities: [ + { id: '100', owner: 'a' }, + { id: '3', owner: 'other-owner' }, + ], + operation: Operations.createCase, + }) + ).resolves.not.toThrow(); + }); + + it('logs a successful authorization when the user has the privileges needed with a feature specifying multiple owners', async () => { + expect.assertions(2); + + featuresStart.getKibanaFeatures.mockReturnValue(([ + { id: '2', cases: ['a', 'other-owner'] }, + ] as unknown) as KibanaFeature[]); + + auth = await Authorization.create({ + request, + securityAuth: securityStart.authz, + getSpace: jest.fn(), + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(mockLogger), + logger: loggingSystemMock.createLogger(), + }); + + await expect( + auth.ensureAuthorized({ + entities: [ + { id: '100', owner: 'a' }, + { id: '3', owner: 'other-owner' }, + ], + operation: Operations.createCase, + }) + ).resolves.not.toThrow(); + + expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "100", + "type": "cases", + }, + }, + "message": "User is creating cases [id=100] as owner \\"a\\"", + }, + ], + Array [ + Object { + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "3", + "type": "cases", + }, + }, + "message": "User is creating cases [id=3] as owner \\"other-owner\\"", + }, + ], + ] + `); + }); + }); + + describe('getAuthorizationFilter', () => { + const feature = { id: '1', cases: ['a', 'b'] }; + + let securityStart: ReturnType; + let featuresStart: jest.Mocked; + let auth: Authorization; + + beforeEach(async () => { + securityStart = securityMock.createStart(); + securityStart.authz.mode.useRbacForRequest.mockReturnValue(true); + securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue( + jest.fn(async () => ({ + hasAllRequested: true, + username: 'super', + privileges: { kibana: [] }, + })) + ); + + featuresStart = featuresPluginMock.createStart(); + featuresStart.getKibanaFeatures.mockReturnValue(([feature] as unknown) as KibanaFeature[]); + + auth = await Authorization.create({ + request, + securityAuth: securityStart.authz, + getSpace: jest.fn(), + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(mockLogger), + logger: loggingSystemMock.createLogger(), + }); + }); + + it('throws and logs an error when there are no registered owners from plugins and security is enabled', async () => { + expect.assertions(2); + + featuresStart.getKibanaFeatures.mockReturnValue([]); + + auth = await Authorization.create({ + request, + securityAuth: securityStart.authz, + getSpace: jest.fn(), + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(mockLogger), + logger: loggingSystemMock.createLogger(), + }); + + try { + await auth.getAuthorizationFilter(Operations.findCases); + } catch (error) { + expect(error.message).toBe('Unauthorized to access cases of any owner'); + } + + expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "error": Object { + "code": "Error", + "message": "Unauthorized to access cases of any owner", + }, + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a cases as any owners", + }, + ], + ] + `); + }); + + it('does not throw an error or log when a feature owner exists and security is disabled', async () => { + expect.assertions(3); + + auth = await Authorization.create({ + request, + getSpace: jest.fn(), + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(mockLogger), + logger: loggingSystemMock.createLogger(), + }); + + const helpersPromise = auth.getAuthorizationFilter(Operations.findCases); + await expect(helpersPromise).resolves.not.toThrow(); + const helpers = await Promise.resolve(helpersPromise); + helpers.ensureSavedObjectsAreAuthorized([ + { id: '1', owner: 'blah' }, + { id: '2', owner: 'something-else' }, + ]); + + expect(helpers.filter).toBeUndefined(); + + expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(`Array []`); + }); + + describe('hasAllRequested: true', () => { + it('logs and does not throw an error when passed the matching owners', async () => { + expect.assertions(3); + + const helpersPromise = auth.getAuthorizationFilter(Operations.findCases); + await expect(helpersPromise).resolves.not.toThrow(); + const helpers = await Promise.resolve(helpersPromise); + helpers.ensureSavedObjectsAreAuthorized([ + { id: '1', owner: 'a' }, + { id: '2', owner: 'b' }, + ]); + + expect(helpers.filter).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "a", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "b", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + } + `); + + expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=1] as owner \\"a\\"", + }, + ], + Array [ + Object { + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "2", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=2] as owner \\"b\\"", + }, + ], + ] + `); + }); + + it('logs and throws an error when passed an invalid owner', async () => { + expect.assertions(4); + + const helpersPromise = auth.getAuthorizationFilter(Operations.findCases); + await expect(helpersPromise).resolves.not.toThrow(); + const helpers = await Promise.resolve(helpersPromise); + try { + helpers.ensureSavedObjectsAreAuthorized([ + { id: '1', owner: 'a' }, + // c is an invalid owner, because it was not registered by a feature + { id: '2', owner: 'c' }, + ]); + } catch (error) { + expect(error.message).toBe('Unauthorized to access cases with owners: "c"'); + } + + expect(helpers.filter).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "a", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "b", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + } + `); + + expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=1] as owner \\"a\\"", + }, + ], + Array [ + Object { + "error": Object { + "code": "Error", + "message": "Unauthorized to access cases with owners: \\"c\\"", + }, + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "2", + "type": "cases", + }, + }, + "message": "Failed attempt to access cases [id=2] as owner \\"c\\"", + }, + ], + ] + `); + }); + }); + + describe('hasAllRequested: false', () => { + beforeEach(async () => { + securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue( + jest.fn(async () => ({ + hasAllRequested: false, + username: 'super', + privileges: { + kibana: [ + { + authorized: true, + privilege: 'a:getCase', + }, + { + authorized: true, + privilege: 'b:getCase', + }, + { + authorized: false, + privilege: 'c:getCase', + }, + ], + }, + })) + ); + + (securityStart.authz.actions.cases.get as jest.MockedFunction< + typeof securityStart.authz.actions.cases.get + >).mockImplementation((owner, opName) => { + return `${owner}:${opName}`; + }); + + featuresStart.getKibanaFeatures.mockReturnValue(([ + { id: 'a', cases: ['a', 'b', 'c'] }, + ] as unknown) as KibanaFeature[]); + + auth = await Authorization.create({ + request, + securityAuth: securityStart.authz, + getSpace: jest.fn(), + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(mockLogger), + logger: loggingSystemMock.createLogger(), + }); + }); + + it('logs and does not throw an error when passed the matching owners', async () => { + expect.assertions(3); + + const helpersPromise = auth.getAuthorizationFilter(Operations.findCases); + await expect(helpersPromise).resolves.not.toThrow(); + const helpers = await Promise.resolve(helpersPromise); + helpers.ensureSavedObjectsAreAuthorized([ + { id: '1', owner: 'a' }, + { id: '2', owner: 'b' }, + ]); + + expect(helpers.filter).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "a", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "b", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + } + `); + + expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=1] as owner \\"a\\"", + }, + ], + Array [ + Object { + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "2", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=2] as owner \\"b\\"", + }, + ], + ] + `); + }); + + it('logs and throws an error when passed an invalid owner', async () => { + expect.assertions(4); + + const helpersPromise = auth.getAuthorizationFilter(Operations.findCases); + await expect(helpersPromise).resolves.not.toThrow(); + const helpers = await Promise.resolve(helpersPromise); + try { + helpers.ensureSavedObjectsAreAuthorized([ + { id: '1', owner: 'a' }, + // c is an invalid owner, because it was not registered by a feature + { id: '2', owner: 'c' }, + ]); + } catch (error) { + expect(error.message).toBe('Unauthorized to access cases with owners: "c"'); + } + + expect(helpers.filter).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "a", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "b", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + } + `); + + expect(mockLogger.log.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=1] as owner \\"a\\"", + }, + ], + Array [ + Object { + "error": Object { + "code": "Error", + "message": "Unauthorized to access cases with owners: \\"c\\"", + }, + "event": Object { + "action": "case_find", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "2", + "type": "cases", + }, + }, + "message": "Failed attempt to access cases [id=2] as owner \\"c\\"", + }, + ], + ] + `); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/authorization/authorization.ts b/x-pack/plugins/cases/server/authorization/authorization.ts new file mode 100644 index 0000000000000..a363874857d56 --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/authorization.ts @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { KibanaRequest, Logger } from 'kibana/server'; +import Boom from '@hapi/boom'; +import { SecurityPluginStart } from '../../../security/server'; +import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; +import { AuthFilterHelpers, GetSpaceFn } from './types'; +import { getOwnersFilter } from './utils'; +import { AuthorizationAuditLogger, OperationDetails } from '.'; +import { createCaseError } from '../common'; +import { OwnerEntity } from './types'; + +/** + * This class handles ensuring that the user making a request has the correct permissions + * for the API request. + */ +export class Authorization { + private readonly request: KibanaRequest; + private readonly securityAuth: SecurityPluginStart['authz'] | undefined; + private readonly featureCaseOwners: Set; + private readonly auditLogger: AuthorizationAuditLogger; + + private constructor({ + request, + securityAuth, + caseOwners, + auditLogger, + }: { + request: KibanaRequest; + securityAuth?: SecurityPluginStart['authz']; + caseOwners: Set; + auditLogger: AuthorizationAuditLogger; + }) { + this.request = request; + this.securityAuth = securityAuth; + this.featureCaseOwners = caseOwners; + this.auditLogger = auditLogger; + } + + /** + * Creates an Authorization object. + */ + static async create({ + request, + securityAuth, + getSpace, + features, + auditLogger, + logger, + }: { + request: KibanaRequest; + securityAuth?: SecurityPluginStart['authz']; + getSpace: GetSpaceFn; + features: FeaturesPluginStart; + auditLogger: AuthorizationAuditLogger; + logger: Logger; + }): Promise { + // Since we need to do async operations, this static method handles that before creating the Auth class + let caseOwners: Set; + try { + const disabledFeatures = new Set((await getSpace(request))?.disabledFeatures ?? []); + + caseOwners = new Set( + features + .getKibanaFeatures() + // get all the features' cases owners that aren't disabled + .filter(({ id }) => !disabledFeatures.has(id)) + .flatMap((feature) => feature.cases ?? []) + ); + } catch (error) { + throw createCaseError({ + message: `Failed to create Authorization class: ${error}`, + error, + logger, + }); + } + + return new Authorization({ request, securityAuth, caseOwners, auditLogger }); + } + + private shouldCheckAuthorization(): boolean { + return this.securityAuth?.mode?.useRbacForRequest(this.request) ?? false; + } + + /** + * Checks that the user making the request for the passed in owners and operation has the correct authorization. This + * function will throw if the user is not authorized for the requested operation and owners. + * + * @param entities an array of entities describing the case owners in conjunction with the saved object ID attempting + * to be authorized + * @param operation information describing the operation attempting to be authorized + */ + public async ensureAuthorized({ + entities, + operation, + }: { + entities: OwnerEntity[]; + operation: OperationDetails; + }) { + const logSavedObjects = (error?: Error) => { + for (const entity of entities) { + this.auditLogger.log({ operation, error, entity }); + } + }; + + try { + await this._ensureAuthorized( + entities.map((entity) => entity.owner), + operation + ); + } catch (error) { + logSavedObjects(error); + throw error; + } + + logSavedObjects(); + } + + /** + * Returns an object to filter the saved object find request to the authorized owners of an entity. + */ + public async getAuthorizationFilter(operation: OperationDetails): Promise { + try { + return await this._getAuthorizationFilter(operation); + } catch (error) { + this.auditLogger.log({ error, operation }); + throw error; + } + } + + private async _ensureAuthorized(owners: string[], operation: OperationDetails) { + const { securityAuth } = this; + const areAllOwnersAvailable = owners.every((owner) => this.featureCaseOwners.has(owner)); + + if (securityAuth && this.shouldCheckAuthorization()) { + const requiredPrivileges: string[] = owners.map((owner) => + securityAuth.actions.cases.get(owner, operation.name) + ); + + const checkPrivileges = securityAuth.checkPrivilegesDynamicallyWithRequest(this.request); + const { hasAllRequested } = await checkPrivileges({ + kibana: requiredPrivileges, + }); + + if (!areAllOwnersAvailable) { + /** + * Under most circumstances this would have been caught by `checkPrivileges` as + * a user can't have Privileges to an unknown owner, but super users + * don't actually get "privilege checked" so the made up owner *will* return + * as Privileged. + * This check will ensure we don't accidentally let these through + */ + throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation })); + } + + if (!hasAllRequested) { + throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation })); + } + } else if (!areAllOwnersAvailable) { + throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation })); + } + + // else security is disabled so let the operation proceed + } + + private async _getAuthorizationFilter(operation: OperationDetails): Promise { + const { securityAuth } = this; + if (securityAuth && this.shouldCheckAuthorization()) { + const { authorizedOwners } = await this.getAuthorizedOwners([operation]); + + if (!authorizedOwners.length) { + throw Boom.forbidden( + AuthorizationAuditLogger.createFailureMessage({ owners: authorizedOwners, operation }) + ); + } + + return { + filter: getOwnersFilter(operation.savedObjectType, authorizedOwners), + ensureSavedObjectsAreAuthorized: (entities: OwnerEntity[]) => { + for (const entity of entities) { + if (!authorizedOwners.includes(entity.owner)) { + const error = Boom.forbidden( + AuthorizationAuditLogger.createFailureMessage({ + operation, + owners: [entity.owner], + }) + ); + this.auditLogger.log({ error, operation, entity }); + throw error; + } + + this.auditLogger.log({ operation, entity }); + } + }, + }; + } + + return { + ensureSavedObjectsAreAuthorized: (entities: OwnerEntity[]) => {}, + }; + } + + private async getAuthorizedOwners( + operations: OperationDetails[] + ): Promise<{ + username?: string; + hasAllRequested: boolean; + authorizedOwners: string[]; + }> { + const { securityAuth, featureCaseOwners } = this; + if (securityAuth && this.shouldCheckAuthorization()) { + const checkPrivileges = securityAuth.checkPrivilegesDynamicallyWithRequest(this.request); + const requiredPrivileges = new Map(); + + for (const owner of featureCaseOwners) { + for (const operation of operations) { + requiredPrivileges.set(securityAuth.actions.cases.get(owner, operation.name), owner); + } + } + + const { hasAllRequested, username, privileges } = await checkPrivileges({ + kibana: [...requiredPrivileges.keys()], + }); + + return { + hasAllRequested, + username, + authorizedOwners: hasAllRequested + ? Array.from(featureCaseOwners) + : privileges.kibana.reduce((authorizedOwners, { authorized, privilege }) => { + if (authorized && requiredPrivileges.has(privilege)) { + const owner = requiredPrivileges.get(privilege)!; + authorizedOwners.push(owner); + } + + return authorizedOwners; + }, []), + }; + } else { + return { + hasAllRequested: true, + authorizedOwners: Array.from(featureCaseOwners), + }; + } + } +} diff --git a/x-pack/plugins/cases/server/authorization/index.test.ts b/x-pack/plugins/cases/server/authorization/index.test.ts new file mode 100644 index 0000000000000..ef2a5eed09eaa --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/index.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isWriteOperation, Operations } from '.'; +import { OperationDetails } from './types'; + +describe('index tests', () => { + it('should identify a write operation', () => { + expect(isWriteOperation(Operations.createCase)).toBeTruthy(); + }); + + it('should return false when the operation is not a write operation', () => { + expect(isWriteOperation(Operations.getCase)).toBeFalsy(); + }); + + it('should not identify an invalid operation as a write operation', () => { + expect(isWriteOperation({ name: 'blah' } as OperationDetails)).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/cases/server/authorization/index.ts b/x-pack/plugins/cases/server/authorization/index.ts new file mode 100644 index 0000000000000..9a8b44a4a4f5d --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/index.ts @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { EcsEventCategory, EcsEventOutcome, EcsEventType } from 'kibana/server'; +import { + CASE_COMMENT_SAVED_OBJECT, + CASE_CONFIGURE_SAVED_OBJECT, + CASE_SAVED_OBJECT, + CASE_USER_ACTION_SAVED_OBJECT, +} from '../../common/constants'; +import { Verbs, ReadOperations, WriteOperations, OperationDetails } from './types'; + +export * from './authorization'; +export * from './audit_logger'; +export * from './types'; + +const createVerbs: Verbs = { + present: 'create', + progressive: 'creating', + past: 'created', +}; + +const accessVerbs: Verbs = { + present: 'access', + progressive: 'accessing', + past: 'accessed', +}; + +const updateVerbs: Verbs = { + present: 'update', + progressive: 'updating', + past: 'updated', +}; + +const deleteVerbs: Verbs = { + present: 'delete', + progressive: 'deleting', + past: 'deleted', +}; + +const EVENT_TYPES: Record = { + creation: 'creation', + deletion: 'deletion', + change: 'change', + access: 'access', +}; + +/** + * These values need to match the respective values in this file: x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts + * These are shared between find, get, get all, and delete/delete all + * There currently isn't a use case for a user to delete one comment but not all or differentiating between get, get all, + * and find operations from a privilege stand point. + */ +const DELETE_COMMENT_OPERATION = 'deleteComment'; +const ACCESS_COMMENT_OPERATION = 'getComment'; +const ACCESS_CASE_OPERATION = 'getCase'; + +/** + * Database constant for ECS category for use for audit logging. + */ +export const DATABASE_CATEGORY: EcsEventCategory[] = ['database']; + +/** + * ECS Outcomes for audit logging. + */ +export const ECS_OUTCOMES: Record = { + failure: 'failure', + success: 'success', + unknown: 'unknown', +}; + +/** + * Determines if the passed in operation was a write operation. + * + * @param operation an OperationDetails object describing the operation that occurred + * @returns true if the passed in operation was a write operation + */ +export function isWriteOperation(operation: OperationDetails): boolean { + return Object.values(WriteOperations).includes(operation.name as WriteOperations); +} + +/** + * Definition of all APIs within the cases backend. + */ +export const Operations: Record = { + // case operations + [WriteOperations.CreateCase]: { + ecsType: EVENT_TYPES.creation, + name: WriteOperations.CreateCase, + action: 'case_create', + verbs: createVerbs, + docType: 'case', + savedObjectType: CASE_SAVED_OBJECT, + }, + [WriteOperations.DeleteCase]: { + ecsType: EVENT_TYPES.deletion, + name: WriteOperations.DeleteCase, + action: 'case_delete', + verbs: deleteVerbs, + docType: 'case', + savedObjectType: CASE_SAVED_OBJECT, + }, + [WriteOperations.UpdateCase]: { + ecsType: EVENT_TYPES.change, + name: WriteOperations.UpdateCase, + action: 'case_update', + verbs: updateVerbs, + docType: 'case', + savedObjectType: CASE_SAVED_OBJECT, + }, + [WriteOperations.PushCase]: { + ecsType: EVENT_TYPES.change, + name: WriteOperations.PushCase, + action: 'case_push', + verbs: updateVerbs, + docType: 'case', + savedObjectType: CASE_SAVED_OBJECT, + }, + [WriteOperations.CreateConfiguration]: { + ecsType: EVENT_TYPES.creation, + name: WriteOperations.CreateConfiguration, + action: 'case_configuration_create', + verbs: createVerbs, + docType: 'case configuration', + savedObjectType: CASE_CONFIGURE_SAVED_OBJECT, + }, + [WriteOperations.UpdateConfiguration]: { + ecsType: EVENT_TYPES.change, + name: WriteOperations.UpdateConfiguration, + action: 'case_configuration_update', + verbs: updateVerbs, + docType: 'case configuration', + savedObjectType: CASE_CONFIGURE_SAVED_OBJECT, + }, + [ReadOperations.FindConfigurations]: { + ecsType: EVENT_TYPES.access, + name: ReadOperations.FindConfigurations, + action: 'case_configuration_find', + verbs: accessVerbs, + docType: 'case configurations', + savedObjectType: CASE_CONFIGURE_SAVED_OBJECT, + }, + [ReadOperations.GetCase]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_CASE_OPERATION, + action: 'case_get', + verbs: accessVerbs, + docType: 'case', + savedObjectType: CASE_SAVED_OBJECT, + }, + [ReadOperations.FindCases]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_CASE_OPERATION, + action: 'case_find', + verbs: accessVerbs, + docType: 'cases', + savedObjectType: CASE_SAVED_OBJECT, + }, + [ReadOperations.GetCaseIDsByAlertID]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_CASE_OPERATION, + action: 'case_ids_by_alert_id_get', + verbs: accessVerbs, + docType: 'cases', + savedObjectType: CASE_COMMENT_SAVED_OBJECT, + }, + [ReadOperations.GetTags]: { + ecsType: EVENT_TYPES.access, + name: ReadOperations.GetCase, + action: 'case_tags_get', + verbs: accessVerbs, + docType: 'case', + savedObjectType: CASE_SAVED_OBJECT, + }, + [ReadOperations.GetReporters]: { + ecsType: EVENT_TYPES.access, + name: ReadOperations.GetReporters, + action: 'case_reporters_get', + verbs: accessVerbs, + docType: 'case', + savedObjectType: CASE_SAVED_OBJECT, + }, + // comments operations + [WriteOperations.CreateComment]: { + ecsType: EVENT_TYPES.creation, + name: WriteOperations.CreateComment, + action: 'case_comment_create', + verbs: createVerbs, + docType: 'comments', + savedObjectType: CASE_COMMENT_SAVED_OBJECT, + }, + [WriteOperations.DeleteAllComments]: { + ecsType: EVENT_TYPES.deletion, + name: DELETE_COMMENT_OPERATION, + action: 'case_comment_delete_all', + verbs: deleteVerbs, + docType: 'comments', + savedObjectType: CASE_COMMENT_SAVED_OBJECT, + }, + [WriteOperations.DeleteComment]: { + ecsType: EVENT_TYPES.deletion, + name: DELETE_COMMENT_OPERATION, + action: 'case_comment_delete', + verbs: deleteVerbs, + docType: 'comments', + savedObjectType: CASE_COMMENT_SAVED_OBJECT, + }, + [WriteOperations.UpdateComment]: { + ecsType: EVENT_TYPES.change, + name: WriteOperations.UpdateComment, + action: 'case_comment_update', + verbs: updateVerbs, + docType: 'comments', + savedObjectType: CASE_COMMENT_SAVED_OBJECT, + }, + [ReadOperations.GetComment]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_COMMENT_OPERATION, + action: 'case_comment_get', + verbs: accessVerbs, + docType: 'comments', + savedObjectType: CASE_COMMENT_SAVED_OBJECT, + }, + [ReadOperations.GetAllComments]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_COMMENT_OPERATION, + action: 'case_comment_get_all', + verbs: accessVerbs, + docType: 'comments', + savedObjectType: CASE_COMMENT_SAVED_OBJECT, + }, + [ReadOperations.FindComments]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_COMMENT_OPERATION, + action: 'case_comment_find', + verbs: accessVerbs, + docType: 'comments', + savedObjectType: CASE_COMMENT_SAVED_OBJECT, + }, + // stats operations + [ReadOperations.GetCaseStatuses]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_CASE_OPERATION, + action: 'case_find_statuses', + verbs: accessVerbs, + docType: 'cases', + savedObjectType: CASE_SAVED_OBJECT, + }, + // user actions operations + [ReadOperations.GetUserActions]: { + ecsType: EVENT_TYPES.access, + name: ReadOperations.GetUserActions, + action: 'case_user_actions_get', + verbs: accessVerbs, + docType: 'user actions', + savedObjectType: CASE_USER_ACTION_SAVED_OBJECT, + }, +}; diff --git a/x-pack/plugins/cases/server/authorization/mock.ts b/x-pack/plugins/cases/server/authorization/mock.ts new file mode 100644 index 0000000000000..555d3ac7a9991 --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/mock.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import { Authorization } from './authorization'; + +type Schema = PublicMethodsOf; +export type AuthorizationMock = jest.Mocked; + +export const createAuthorizationMock = () => { + const mocked: AuthorizationMock = { + ensureAuthorized: jest.fn(), + getAuthorizationFilter: jest.fn(), + }; + return mocked; +}; diff --git a/x-pack/plugins/cases/server/authorization/types.ts b/x-pack/plugins/cases/server/authorization/types.ts new file mode 100644 index 0000000000000..4651d45ab3b5f --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/types.ts @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { EcsEventType, KibanaRequest } from 'kibana/server'; +import { KueryNode } from 'src/plugins/data/common'; +import { Space } from '../../../spaces/server'; + +/** + * The tenses for describing the action performed by a API route + */ +export interface Verbs { + present: string; + progressive: string; + past: string; +} + +export type GetSpaceFn = (request: KibanaRequest) => Promise; + +/** + * Read operations for the cases APIs. + * + * NOTE: If you add a value here you'll likely also need to make changes here: + * x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts + */ +export enum ReadOperations { + GetCase = 'getCase', + FindCases = 'findCases', + GetCaseIDsByAlertID = 'getCaseIDsByAlertID', + GetCaseStatuses = 'getCaseStatuses', + GetComment = 'getComment', + GetAllComments = 'getAllComments', + FindComments = 'findComments', + GetTags = 'getTags', + GetReporters = 'getReporters', + FindConfigurations = 'findConfigurations', + GetUserActions = 'getUserActions', +} + +/** + * Write operations for the cases APIs. + * + * NOTE: If you add a value here you'll likely also need to make changes here: + * x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts + */ +export enum WriteOperations { + CreateCase = 'createCase', + DeleteCase = 'deleteCase', + UpdateCase = 'updateCase', + PushCase = 'pushCase', + CreateComment = 'createComment', + DeleteAllComments = 'deleteAllComments', + DeleteComment = 'deleteComment', + UpdateComment = 'updateComment', + CreateConfiguration = 'createConfiguration', + UpdateConfiguration = 'updateConfiguration', +} + +/** + * Defines the structure for a case API route. + */ +export interface OperationDetails { + /** + * The ECS event type that this operation should be audit logged as (creation, deletion, access, etc) + */ + ecsType: EcsEventType; + /** + * The name of the operation to authorize against for the privilege check. + * These values need to match one of the operation strings defined here: x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts + */ + name: string; + /** + * The ECS `event.action` field, should be in the form of _ e.g comment_get, case_fined + */ + action: string; + /** + * The verbs that are associated with this type of operation, these should line up with the event type e.g. creating, created, create etc + */ + verbs: Verbs; + /** + * The readable name of the entity being operated on e.g. case, comment, configurations (make it plural if it reads better that way etc) + */ + docType: string; + /** + * The actual saved object type of the entity e.g. cases, cases-comments + */ + savedObjectType: string; +} + +/** + * Describes an entity with the necessary fields to identify if the user is authorized to interact with the saved object + * returned from some find query. + */ +export interface OwnerEntity { + owner: string; + id: string; +} + +/** + * Function callback for making sure the found saved objects are of the authorized owner + */ +export type EnsureSOAuthCallback = (entities: OwnerEntity[]) => void; + +/** + * Defines the helper methods and necessary information for authorizing the find API's request. + */ +export interface AuthFilterHelpers { + /** + * The owner filter to pass to the saved object client's find operation that is scoped to the authorized owners + */ + filter?: KueryNode; + /** + * Utility function for checking that the returned entities are in fact authorized for the user making the request + */ + ensureSavedObjectsAreAuthorized: EnsureSOAuthCallback; +} diff --git a/x-pack/plugins/cases/server/authorization/utils.test.ts b/x-pack/plugins/cases/server/authorization/utils.test.ts new file mode 100644 index 0000000000000..3ebf6ee398e38 --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/utils.test.ts @@ -0,0 +1,297 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { nodeBuilder } from '../../../../../src/plugins/data/common'; +import { OWNER_FIELD } from '../../common'; +import { + combineFilterWithAuthorizationFilter, + ensureFieldIsSafeForQuery, + getOwnersFilter, + includeFieldsRequiredForAuthentication, +} from './utils'; + +describe('utils', () => { + describe('combineFilterWithAuthorizationFilter', () => { + it('returns undefined if neither a filter or authorizationFilter are passed', () => { + expect(combineFilterWithAuthorizationFilter()).toBeUndefined(); + }); + + it('returns a single KueryNode when only a filter is passed in', () => { + const node = nodeBuilder.is('a', 'hello'); + expect(combineFilterWithAuthorizationFilter(node)).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "a", + }, + Object { + "type": "literal", + "value": "hello", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + } + `); + }); + + it('returns a single KueryNode when only an authorizationFilter is passed in', () => { + const node = nodeBuilder.is('a', 'hello'); + expect(combineFilterWithAuthorizationFilter(undefined, node)).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "a", + }, + Object { + "type": "literal", + "value": "hello", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + } + `); + }); + + it("returns a single KueryNode and'ing together the passed in parameters", () => { + const node = nodeBuilder.is('a', 'hello'); + const node2 = nodeBuilder.is('b', 'hi'); + + expect(combineFilterWithAuthorizationFilter(node, node2)).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "a", + }, + Object { + "type": "literal", + "value": "hello", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "b", + }, + Object { + "type": "literal", + "value": "hi", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + } + `); + }); + + it("returns a single KueryNode and'ing together the passed in parameters in opposite order", () => { + const node = nodeBuilder.is('a', 'hello'); + const node2 = nodeBuilder.is('b', 'hi'); + + expect(combineFilterWithAuthorizationFilter(node2, node)).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "b", + }, + Object { + "type": "literal", + "value": "hi", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "a", + }, + Object { + "type": "literal", + "value": "hello", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + } + `); + }); + }); + + describe('includeFieldsRequiredForAuthentication', () => { + it('returns undefined when the fields parameter is not specified', () => { + expect(includeFieldsRequiredForAuthentication()).toBeUndefined(); + }); + + it('returns an array with a single entry containing the owner field', () => { + expect(includeFieldsRequiredForAuthentication([])).toStrictEqual([OWNER_FIELD]); + }); + + it('returns an array without duplicates and including the owner field', () => { + expect(includeFieldsRequiredForAuthentication(['a', 'b', 'a'])).toStrictEqual([ + 'a', + 'b', + OWNER_FIELD, + ]); + }); + }); + + describe('ensureFieldIsSafeForQuery', () => { + it("throws an error if field contains character that aren't safe in a KQL query", () => { + expect(() => ensureFieldIsSafeForQuery('id', 'cases-*')).toThrowError( + `expected id not to include invalid character: *` + ); + + expect(() => ensureFieldIsSafeForQuery('id', '<=""')).toThrowError( + `expected id not to include invalid character: <=` + ); + + expect(() => ensureFieldIsSafeForQuery('id', '>=""')).toThrowError( + `expected id not to include invalid character: >=` + ); + + expect(() => ensureFieldIsSafeForQuery('id', '1 or caseid:123')).toThrowError( + `expected id not to include whitespace and invalid character: :` + ); + + expect(() => ensureFieldIsSafeForQuery('id', ') or caseid:123')).toThrowError( + `expected id not to include whitespace and invalid characters: ), :` + ); + + expect(() => ensureFieldIsSafeForQuery('id', 'some space')).toThrowError( + `expected id not to include whitespace` + ); + }); + + it("doesn't throw an error if field is safe as part of a KQL query", () => { + expect(() => ensureFieldIsSafeForQuery('id', '123-0456-678')).not.toThrow(); + }); + }); + + describe('getOwnersFilter', () => { + it('returns undefined when the owners parameter is an empty array', () => { + expect(getOwnersFilter('a', [])).toBeUndefined(); + }); + + it('constructs a KueryNode with only a single node', () => { + expect(getOwnersFilter('a', ['hello'])).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "a.attributes.owner", + }, + Object { + "type": "literal", + "value": "hello", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + } + `); + }); + + it("constructs a KueryNode or'ing together two filters", () => { + expect(getOwnersFilter('a', ['hello', 'hi'])).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "a.attributes.owner", + }, + Object { + "type": "literal", + "value": "hello", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "a.attributes.owner", + }, + Object { + "type": "literal", + "value": "hi", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/authorization/utils.ts b/x-pack/plugins/cases/server/authorization/utils.ts new file mode 100644 index 0000000000000..19dc37d0c3fdf --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/utils.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { remove, uniq } from 'lodash'; +import { nodeBuilder, KueryNode } from '../../../../../src/plugins/data/common'; +import { OWNER_FIELD } from '../../common/api'; + +export const getOwnersFilter = ( + savedObjectType: string, + owners: string[] +): KueryNode | undefined => { + if (owners.length <= 0) { + return; + } + + return nodeBuilder.or( + owners.reduce((query, owner) => { + ensureFieldIsSafeForQuery(OWNER_FIELD, owner); + query.push(nodeBuilder.is(`${savedObjectType}.attributes.${OWNER_FIELD}`, owner)); + return query; + }, []) + ); +}; + +export const combineFilterWithAuthorizationFilter = ( + filter?: KueryNode, + authorizationFilter?: KueryNode +) => { + if (!filter && !authorizationFilter) { + return; + } + + const kueries = [ + ...(filter !== undefined ? [filter] : []), + ...(authorizationFilter !== undefined ? [authorizationFilter] : []), + ]; + return nodeBuilder.and(kueries); +}; + +export const ensureFieldIsSafeForQuery = (field: string, value: string): boolean => { + const invalid = value.match(/([>=<\*:()]+|\s+)/g); + if (invalid) { + const whitespace = remove(invalid, (chars) => chars.trim().length === 0); + const errors = []; + if (whitespace.length) { + errors.push(`whitespace`); + } + if (invalid.length) { + errors.push(`invalid character${invalid.length > 1 ? `s` : ``}: ${invalid?.join(`, `)}`); + } + throw new Error(`expected ${field} not to include ${errors.join(' and ')}`); + } + return true; +}; + +export const includeFieldsRequiredForAuthentication = (fields?: string[]): string[] | undefined => { + if (fields === undefined) { + return; + } + return uniq([...fields, OWNER_FIELD]); +}; diff --git a/x-pack/plugins/cases/server/client/alerts/client.ts b/x-pack/plugins/cases/server/client/alerts/client.ts new file mode 100644 index 0000000000000..19dc95982613f --- /dev/null +++ b/x-pack/plugins/cases/server/client/alerts/client.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseStatuses } from '../../../common/api'; +import { AlertInfo } from '../../common'; +import { CasesClientGetAlertsResponse } from './types'; +import { get } from './get'; +import { updateStatus } from './update_status'; +import { CasesClientArgs } from '../types'; + +/** + * Defines the fields necessary to update an alert's status. + */ +export interface UpdateAlertRequest { + id: string; + index: string; + status: CaseStatuses; +} + +export interface AlertUpdateStatus { + alerts: UpdateAlertRequest[]; +} + +export interface AlertGet { + alertsInfo: AlertInfo[]; +} + +export interface AlertSubClient { + get(args: AlertGet): Promise; + updateStatus(args: AlertUpdateStatus): Promise; +} + +export const createAlertsSubClient = (clientArgs: CasesClientArgs): AlertSubClient => { + const alertsSubClient: AlertSubClient = { + get: (params: AlertGet) => get(params, clientArgs), + updateStatus: (params: AlertUpdateStatus) => updateStatus(params, clientArgs), + }; + + return Object.freeze(alertsSubClient); +}; diff --git a/x-pack/plugins/cases/server/client/alerts/get.ts b/x-pack/plugins/cases/server/client/alerts/get.ts index 88298450e499a..186f914aa2cd7 100644 --- a/x-pack/plugins/cases/server/client/alerts/get.ts +++ b/x-pack/plugins/cases/server/client/alerts/get.ts @@ -5,24 +5,19 @@ * 2.0. */ -import { ElasticsearchClient, Logger } from 'kibana/server'; import { AlertInfo } from '../../common'; -import { AlertServiceContract } from '../../services'; import { CasesClientGetAlertsResponse } from './types'; +import { CasesClientArgs } from '..'; interface GetParams { - alertsService: AlertServiceContract; alertsInfo: AlertInfo[]; - scopedClusterClient: ElasticsearchClient; - logger: Logger; } -export const get = async ({ - alertsService, - alertsInfo, - scopedClusterClient, - logger, -}: GetParams): Promise => { +export const get = async ( + { alertsInfo }: GetParams, + clientArgs: CasesClientArgs +): Promise => { + const { alertsService, scopedClusterClient, logger } = clientArgs; if (alertsInfo.length === 0) { return []; } diff --git a/x-pack/plugins/cases/server/client/alerts/update_status.test.ts b/x-pack/plugins/cases/server/client/alerts/update_status.test.ts deleted file mode 100644 index d6456cb3183ef..0000000000000 --- a/x-pack/plugins/cases/server/client/alerts/update_status.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { CaseStatuses } from '../../../common'; -import { createMockSavedObjectsRepository } from '../../routes/api/__fixtures__'; -import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; - -describe('updateAlertsStatus', () => { - it('updates the status of the alert correctly', async () => { - const savedObjectsClient = createMockSavedObjectsRepository(); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - await casesClient.client.updateAlertsStatus({ - alerts: [{ id: 'alert-id-1', index: '.siem-signals', status: CaseStatuses.closed }], - }); - - expect(casesClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ - logger: expect.anything(), - scopedClusterClient: expect.anything(), - alerts: [{ id: 'alert-id-1', index: '.siem-signals', status: CaseStatuses.closed }], - }); - }); -}); diff --git a/x-pack/plugins/cases/server/client/alerts/update_status.ts b/x-pack/plugins/cases/server/client/alerts/update_status.ts index cd6f97273d6d7..3c7f60ecae15d 100644 --- a/x-pack/plugins/cases/server/client/alerts/update_status.ts +++ b/x-pack/plugins/cases/server/client/alerts/update_status.ts @@ -5,22 +5,17 @@ * 2.0. */ -import { ElasticsearchClient, Logger } from 'src/core/server'; -import { AlertServiceContract } from '../../services'; -import { UpdateAlertRequest } from '../types'; +import { UpdateAlertRequest } from './client'; +import { CasesClientArgs } from '..'; interface UpdateAlertsStatusArgs { - alertsService: AlertServiceContract; alerts: UpdateAlertRequest[]; - scopedClusterClient: ElasticsearchClient; - logger: Logger; } -export const updateAlertsStatus = async ({ - alertsService, - alerts, - scopedClusterClient, - logger, -}: UpdateAlertsStatusArgs): Promise => { +export const updateStatus = async ( + { alerts }: UpdateAlertsStatusArgs, + clientArgs: CasesClientArgs +): Promise => { + const { alertsService, scopedClusterClient, logger } = clientArgs; await alertsService.updateAlertsStatus({ alerts, scopedClusterClient, logger }); }; diff --git a/x-pack/plugins/cases/server/client/comments/add.ts b/x-pack/plugins/cases/server/client/attachments/add.ts similarity index 63% rename from x-pack/plugins/cases/server/client/comments/add.ts rename to x-pack/plugins/cases/server/client/attachments/add.ts index 376e0e2c8868e..9008e0fc28dee 100644 --- a/x-pack/plugins/cases/server/client/comments/add.ts +++ b/x-pack/plugins/cases/server/client/attachments/add.ts @@ -10,8 +10,13 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObject, SavedObjectsClientContract, Logger } from 'src/core/server'; -import { decodeCommentRequest, isCommentRequestTypeGenAlert } from '../../routes/api/utils'; +import { + SavedObject, + SavedObjectsClientContract, + Logger, + SavedObjectsUtils, +} from '../../../../../../src/core/server'; +import { nodeBuilder } from '../../../../../../src/plugins/data/common'; import { throwErrors, @@ -20,47 +25,62 @@ import { CaseStatuses, CaseType, SubCaseAttributes, - CommentRequest, CaseResponse, User, - CommentRequestAlertType, AlertCommentRequestRt, + CommentRequest, } from '../../../common'; import { buildCaseUserActionItem, buildCommentUserActionItem, } from '../../services/user_actions/helpers'; -import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; -import { CommentableCase, createAlertUpdateRequest } from '../../common'; -import { CasesClientHandler } from '..'; +import { AttachmentService, CasesService, CaseUserActionService } from '../../services'; +import { + CommentableCase, + createAlertUpdateRequest, + isCommentRequestTypeGenAlert, +} from '../../common'; +import { CasesClientArgs, CasesClientInternal } from '..'; import { createCaseError } from '../../common/error'; -import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; -import { ENABLE_CASE_CONNECTOR, MAX_GENERATED_ALERTS_PER_SUB_CASE } from '../../../common'; +import { + ENABLE_CASE_CONNECTOR, + MAX_GENERATED_ALERTS_PER_SUB_CASE, + CASE_COMMENT_SAVED_OBJECT, +} from '../../../common'; + +import { decodeCommentRequest } from '../utils'; +import { Operations } from '../../authorization'; async function getSubCase({ caseService, - savedObjectsClient, + unsecuredSavedObjectsClient, caseId, createdAt, userActionService, user, }: { - caseService: CaseServiceSetup; - savedObjectsClient: SavedObjectsClientContract; + caseService: CasesService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; caseId: string; createdAt: string; - userActionService: CaseUserActionServiceSetup; + userActionService: CaseUserActionService; user: User; }): Promise> { - const mostRecentSubCase = await caseService.getMostRecentSubCase(savedObjectsClient, caseId); + const mostRecentSubCase = await caseService.getMostRecentSubCase( + unsecuredSavedObjectsClient, + caseId + ); if (mostRecentSubCase && mostRecentSubCase.attributes.status !== CaseStatuses.closed) { const subCaseAlertsAttachement = await caseService.getAllSubCaseComments({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, id: mostRecentSubCase.id, options: { fields: [], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + filter: nodeBuilder.is( + `${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, + CommentType.generatedAlert + ), page: 1, perPage: 1, }, @@ -72,13 +92,13 @@ async function getSubCase({ } const newSubCase = await caseService.createSubCase({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, createdAt, caseId, createdBy: user, }); - await userActionService.postUserActions({ - client: savedObjectsClient, + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, actions: [ buildCaseUserActionItem({ action: 'create', @@ -88,31 +108,27 @@ async function getSubCase({ subCaseId: newSubCase.id, fields: ['status', 'sub_case'], newValue: JSON.stringify({ status: newSubCase.attributes.status }), + owner: newSubCase.attributes.owner, }), ], }); return newSubCase; } -interface AddCommentFromRuleArgs { - casesClient: CasesClientHandler; - caseId: string; - comment: CommentRequestAlertType; - savedObjectsClient: SavedObjectsClientContract; - caseService: CaseServiceSetup; - userActionService: CaseUserActionServiceSetup; - logger: Logger; -} +const addGeneratedAlerts = async ( + { caseId, comment }: AddArgs, + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise => { + const { + unsecuredSavedObjectsClient, + attachmentService, + caseService, + userActionService, + logger, + authorization, + } = clientArgs; -const addGeneratedAlerts = async ({ - savedObjectsClient, - caseService, - userActionService, - casesClient, - caseId, - comment, - logger, -}: AddCommentFromRuleArgs): Promise => { const query = pipe( AlertCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) @@ -127,9 +143,15 @@ const addGeneratedAlerts = async ({ try { const createdDate = new Date().toISOString(); + const savedObjectID = SavedObjectsUtils.generateId(); + + await authorization.ensureAuthorized({ + entities: [{ owner: comment.owner, id: savedObjectID }], + operation: Operations.createComment, + }); const caseInfo = await caseService.getCase({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, id: caseId, }); @@ -148,7 +170,7 @@ const addGeneratedAlerts = async ({ const subCase = await getSubCase({ caseService, - savedObjectsClient, + unsecuredSavedObjectsClient, caseId, createdAt: createdDate, userActionService, @@ -159,14 +181,20 @@ const addGeneratedAlerts = async ({ logger, collection: caseInfo, subCase, - soClient: savedObjectsClient, - service: caseService, + unsecuredSavedObjectsClient, + caseService, + attachmentService, }); const { comment: newComment, commentableCase: updatedCase, - } = await commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }); + } = await commentableCase.createComment({ + createdDate, + user: userDetails, + commentReq: query, + id: savedObjectID, + }); if ( (newComment.attributes.type === CommentType.alert || @@ -177,13 +205,13 @@ const addGeneratedAlerts = async ({ comment: query, status: subCase.attributes.status, }); - await casesClient.updateAlertsStatus({ + await casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate, }); } - await userActionService.postUserActions({ - client: savedObjectsClient, + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, actions: [ buildCommentUserActionItem({ action: 'create', @@ -194,6 +222,7 @@ const addGeneratedAlerts = async ({ commentId: newComment.id, fields: ['comment'], newValue: JSON.stringify(query), + owner: newComment.attributes.owner, }), ], }); @@ -209,25 +238,27 @@ const addGeneratedAlerts = async ({ }; async function getCombinedCase({ - service, - client, + caseService, + attachmentService, + unsecuredSavedObjectsClient, id, logger, }: { - service: CaseServiceSetup; - client: SavedObjectsClientContract; + caseService: CasesService; + attachmentService: AttachmentService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; id: string; logger: Logger; }): Promise { const [casePromise, subCasePromise] = await Promise.allSettled([ - service.getCase({ - client, + caseService.getCase({ + unsecuredSavedObjectsClient, id, }), ...(ENABLE_CASE_CONNECTOR ? [ - service.getSubCase({ - client, + caseService.getSubCase({ + unsecuredSavedObjectsClient, id, }), ] @@ -236,16 +267,17 @@ async function getCombinedCase({ if (subCasePromise.status === 'fulfilled') { if (subCasePromise.value.references.length > 0) { - const caseValue = await service.getCase({ - client, + const caseValue = await caseService.getCase({ + unsecuredSavedObjectsClient, id: subCasePromise.value.references[0].id, }); return new CommentableCase({ logger, collection: caseValue, subCase: subCasePromise.value, - service, - soClient: client, + caseService, + attachmentService, + unsecuredSavedObjectsClient, }); } else { throw Boom.badRequest('Sub case found without reference to collection'); @@ -258,38 +290,53 @@ async function getCombinedCase({ return new CommentableCase({ logger, collection: casePromise.value, - service, - soClient: client, + caseService, + attachmentService, + unsecuredSavedObjectsClient, }); } } -interface AddCommentArgs { - casesClient: CasesClientHandler; +/** + * The arguments needed for creating a new attachment to a case. + */ +export interface AddArgs { + /** + * The case ID that this attachment will be associated with + */ caseId: string; + /** + * The attachment values. + */ comment: CommentRequest; - savedObjectsClient: SavedObjectsClientContract; - caseService: CaseServiceSetup; - userActionService: CaseUserActionServiceSetup; - user: User; - logger: Logger; } -export const addComment = async ({ - savedObjectsClient, - caseService, - userActionService, - casesClient, - caseId, - comment, - user, - logger, -}: AddCommentArgs): Promise => { +/** + * Create an attachment to a case. + * + * @ignore + */ +export const addComment = async ( + addArgs: AddArgs, + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise => { + const { comment, caseId } = addArgs; const query = pipe( CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); + const { + unsecuredSavedObjectsClient, + caseService, + userActionService, + attachmentService, + user, + logger, + authorization, + } = clientArgs; + if (isCommentRequestTypeGenAlert(comment)) { if (!ENABLE_CASE_CONNECTOR) { throw Boom.badRequest( @@ -297,24 +344,24 @@ export const addComment = async ({ ); } - return addGeneratedAlerts({ - caseId, - comment, - casesClient, - savedObjectsClient, - userActionService, - caseService, - logger, - }); + return addGeneratedAlerts(addArgs, clientArgs, casesClientInternal); } decodeCommentRequest(comment); try { + const savedObjectID = SavedObjectsUtils.generateId(); + + await authorization.ensureAuthorized({ + operation: Operations.createComment, + entities: [{ owner: comment.owner, id: savedObjectID }], + }); + const createdDate = new Date().toISOString(); const combinedCase = await getCombinedCase({ - service: caseService, - client: savedObjectsClient, + caseService, + attachmentService, + unsecuredSavedObjectsClient, id: caseId, logger, }); @@ -331,6 +378,7 @@ export const addComment = async ({ createdDate, user: userInfo, commentReq: query, + id: savedObjectID, }); if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { @@ -339,13 +387,13 @@ export const addComment = async ({ status: updatedCase.status, }); - await casesClient.updateAlertsStatus({ + await casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate, }); } - await userActionService.postUserActions({ - client: savedObjectsClient, + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, actions: [ buildCommentUserActionItem({ action: 'create', @@ -356,6 +404,7 @@ export const addComment = async ({ commentId: newComment.id, fields: ['comment'], newValue: JSON.stringify(query), + owner: newComment.attributes.owner, }), ], }); diff --git a/x-pack/plugins/cases/server/client/attachments/client.ts b/x-pack/plugins/cases/server/client/attachments/client.ts new file mode 100644 index 0000000000000..1f6945a9d0584 --- /dev/null +++ b/x-pack/plugins/cases/server/client/attachments/client.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { CommentResponse } from '../../../common/api'; + +import { CasesClientInternal } from '../client_internal'; +import { IAllCommentsResponse, ICaseResponse, ICommentsResponse } from '../typedoc_interfaces'; +import { CasesClientArgs } from '../types'; +import { AddArgs, addComment } from './add'; +import { DeleteAllArgs, deleteAll, DeleteArgs, deleteComment } from './delete'; +import { find, FindArgs, get, getAll, GetAllArgs, GetArgs } from './get'; +import { update, UpdateArgs } from './update'; + +/** + * API for interacting with the attachments to a case. + */ +export interface AttachmentsSubClient { + /** + * Adds an attachment to a case. + */ + add(params: AddArgs): Promise; + /** + * Deletes all attachments associated with a single case. + */ + deleteAll(deleteAllArgs: DeleteAllArgs): Promise; + /** + * Deletes a single attachment for a specific case. + */ + delete(deleteArgs: DeleteArgs): Promise; + /** + * Retrieves all comments matching the search criteria. + */ + find(findArgs: FindArgs): Promise; + /** + * Gets all attachments for a single case. + */ + getAll(getAllArgs: GetAllArgs): Promise; + /** + * Retrieves a single attachment for a case. + */ + get(getArgs: GetArgs): Promise; + /** + * Updates a specific attachment. + * + * The request must include all fields for the attachment. Even the fields that are not changing. + */ + update(updateArgs: UpdateArgs): Promise; +} + +/** + * Creates an API object for interacting with attachments. + * + * @ignore + */ +export const createAttachmentsSubClient = ( + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): AttachmentsSubClient => { + const attachmentSubClient: AttachmentsSubClient = { + add: (params: AddArgs) => addComment(params, clientArgs, casesClientInternal), + deleteAll: (deleteAllArgs: DeleteAllArgs) => deleteAll(deleteAllArgs, clientArgs), + delete: (deleteArgs: DeleteArgs) => deleteComment(deleteArgs, clientArgs), + find: (findArgs: FindArgs) => find(findArgs, clientArgs), + getAll: (getAllArgs: GetAllArgs) => getAll(getAllArgs, clientArgs), + get: (getArgs: GetArgs) => get(getArgs, clientArgs), + update: (updateArgs: UpdateArgs) => update(updateArgs, clientArgs), + }; + + return Object.freeze(attachmentSubClient); +}; diff --git a/x-pack/plugins/cases/server/client/attachments/delete.ts b/x-pack/plugins/cases/server/client/attachments/delete.ts new file mode 100644 index 0000000000000..d935a0c8f09db --- /dev/null +++ b/x-pack/plugins/cases/server/client/attachments/delete.ts @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../common/constants'; + +import { AssociationType } from '../../../common/api'; +import { CasesClientArgs } from '../types'; +import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; +import { createCaseError } from '../../common/error'; +import { checkEnabledCaseConnectorOrThrow } from '../../common'; +import { Operations } from '../../authorization'; + +/** + * Parameters for deleting all comments of a case or sub case. + */ +export interface DeleteAllArgs { + /** + * The case ID to delete all attachments for + */ + caseID: string; + /** + * If specified the caseID will be ignored and this value will be used to find a sub case for deleting all the attachments + */ + subCaseID?: string; +} + +/** + * Parameters for deleting a single attachment of a case or sub case. + */ +export interface DeleteArgs { + /** + * The case ID to delete an attachment from + */ + caseID: string; + /** + * The attachment ID to delete + */ + attachmentID: string; + /** + * If specified the caseID will be ignored and this value will be used to find a sub case for deleting the attachment + */ + subCaseID?: string; +} + +/** + * Delete all comments for a case or sub case. + * + * @ignore + */ +export async function deleteAll( + { caseID, subCaseID }: DeleteAllArgs, + clientArgs: CasesClientArgs +): Promise { + const { + user, + unsecuredSavedObjectsClient, + caseService, + attachmentService, + userActionService, + logger, + authorization, + } = clientArgs; + + try { + checkEnabledCaseConnectorOrThrow(subCaseID); + + const id = subCaseID ?? caseID; + const comments = await caseService.getCommentsByAssociation({ + unsecuredSavedObjectsClient, + id, + associationType: subCaseID ? AssociationType.subCase : AssociationType.case, + }); + + if (comments.total <= 0) { + throw Boom.notFound(`No comments found for ${id}.`); + } + + await authorization.ensureAuthorized({ + operation: Operations.deleteAllComments, + entities: comments.saved_objects.map((comment) => ({ + owner: comment.attributes.owner, + id: comment.id, + })), + }); + + await Promise.all( + comments.saved_objects.map((comment) => + attachmentService.delete({ + unsecuredSavedObjectsClient, + attachmentId: comment.id, + }) + ) + ); + + const deleteDate = new Date().toISOString(); + + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, + actions: comments.saved_objects.map((comment) => + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: user, + caseId: caseID, + subCaseId: subCaseID, + commentId: comment.id, + fields: ['comment'], + owner: comment.attributes.owner, + }) + ), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to delete all comments case id: ${caseID} sub case id: ${subCaseID}: ${error}`, + error, + logger, + }); + } +} + +/** + * Deletes an attachment + * + * @ignore + */ +export async function deleteComment( + { caseID, attachmentID, subCaseID }: DeleteArgs, + clientArgs: CasesClientArgs +) { + const { + user, + unsecuredSavedObjectsClient, + attachmentService, + userActionService, + logger, + authorization, + } = clientArgs; + + try { + checkEnabledCaseConnectorOrThrow(subCaseID); + + const deleteDate = new Date().toISOString(); + + const myComment = await attachmentService.get({ + unsecuredSavedObjectsClient, + attachmentId: attachmentID, + }); + + if (myComment == null) { + throw Boom.notFound(`This comment ${attachmentID} does not exist anymore.`); + } + + await authorization.ensureAuthorized({ + entities: [{ owner: myComment.attributes.owner, id: myComment.id }], + operation: Operations.deleteComment, + }); + + const type = subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const id = subCaseID ?? caseID; + + const caseRef = myComment.references.find((c) => c.type === type); + if (caseRef == null || (caseRef != null && caseRef.id !== id)) { + throw Boom.notFound(`This comment ${attachmentID} does not exist in ${id}.`); + } + + await attachmentService.delete({ + unsecuredSavedObjectsClient, + attachmentId: attachmentID, + }); + + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, + actions: [ + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: user, + caseId: id, + subCaseId: subCaseID, + commentId: attachmentID, + fields: ['comment'], + owner: myComment.attributes.owner, + }), + ], + }); + } catch (error) { + throw createCaseError({ + message: `Failed to delete comment: ${caseID} comment id: ${attachmentID} sub case id: ${subCaseID}: ${error}`, + error, + logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/attachments/get.ts b/x-pack/plugins/cases/server/client/attachments/get.ts new file mode 100644 index 0000000000000..e15bdcc7c8c2b --- /dev/null +++ b/x-pack/plugins/cases/server/client/attachments/get.ts @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import Boom from '@hapi/boom'; +import { SavedObjectsFindResponse } from 'kibana/server'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; + +import { + AllCommentsResponse, + AllCommentsResponseRt, + AssociationType, + CommentAttributes, + CommentResponse, + CommentResponseRt, + CommentsResponse, + CommentsResponseRt, + FindQueryParams, +} from '../../../common/api'; +import { + checkEnabledCaseConnectorOrThrow, + defaultSortField, + transformComments, + flattenCommentSavedObject, + flattenCommentSavedObjects, +} from '../../common'; +import { createCaseError } from '../../common/error'; +import { defaultPage, defaultPerPage } from '../../routes/api'; +import { CasesClientArgs } from '../types'; +import { combineFilters, stringToKueryNode } from '../utils'; +import { Operations } from '../../authorization'; +import { includeFieldsRequiredForAuthentication } from '../../authorization/utils'; + +/** + * Parameters for finding attachments of a case + */ +export interface FindArgs { + /** + * The case ID for finding associated attachments + */ + caseID: string; + /** + * Optional parameters for filtering the returned attachments + */ + queryParams?: FindQueryParams; +} + +/** + * Parameters for retrieving all attachments of a case + */ +export interface GetAllArgs { + /** + * The case ID to retrieve all attachments for + */ + caseID: string; + /** + * Optionally include the attachments associated with a sub case + */ + includeSubCaseComments?: boolean; + /** + * If included the case ID will be ignored and the attachments will be retrieved from the specified ID of the sub case + */ + subCaseID?: string; +} + +export interface GetArgs { + /** + * The ID of the case to retrieve an attachment from + */ + caseID: string; + /** + * The ID of the attachment to retrieve + */ + attachmentID: string; +} + +/** + * Retrieves the attachments for a case entity. This support pagination. + * + * @ignore + */ +export async function find( + { caseID, queryParams }: FindArgs, + clientArgs: CasesClientArgs +): Promise { + const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + + try { + checkEnabledCaseConnectorOrThrow(queryParams?.subCaseId); + + const { + filter: authorizationFilter, + ensureSavedObjectsAreAuthorized, + } = await authorization.getAuthorizationFilter(Operations.findComments); + + const id = queryParams?.subCaseId ?? caseID; + const associationType = queryParams?.subCaseId ? AssociationType.subCase : AssociationType.case; + const { filter, ...queryWithoutFilter } = queryParams ?? {}; + + // if the fields property was defined, make sure we include the 'owner' field in the response + const fields = includeFieldsRequiredForAuthentication(queryWithoutFilter.fields); + + // combine any passed in filter property and the filter for the appropriate owner + const combinedFilter = combineFilters([stringToKueryNode(filter), authorizationFilter]); + + const args = queryParams + ? { + caseService, + unsecuredSavedObjectsClient, + id, + options: { + // We need this because the default behavior of getAllCaseComments is to return all the comments + // unless the page and/or perPage is specified. Since we're spreading the query after the request can + // still override this behavior. + page: defaultPage, + perPage: defaultPerPage, + sortField: 'created_at', + filter: combinedFilter, + ...queryWithoutFilter, + fields, + }, + associationType, + } + : { + caseService, + unsecuredSavedObjectsClient, + id, + options: { + page: defaultPage, + perPage: defaultPerPage, + sortField: 'created_at', + filter: combinedFilter, + }, + associationType, + }; + + const theComments = await caseService.getCommentsByAssociation(args); + + ensureSavedObjectsAreAuthorized( + theComments.saved_objects.map((comment) => ({ + owner: comment.attributes.owner, + id: comment.id, + })) + ); + + return CommentsResponseRt.encode(transformComments(theComments)); + } catch (error) { + throw createCaseError({ + message: `Failed to find comments case id: ${caseID}: ${error}`, + error, + logger, + }); + } +} + +/** + * Retrieves a single attachment by its ID. + * + * @ignore + */ +export async function get( + { attachmentID, caseID }: GetArgs, + clientArgs: CasesClientArgs +): Promise { + const { attachmentService, unsecuredSavedObjectsClient, logger, authorization } = clientArgs; + + try { + const comment = await attachmentService.get({ + unsecuredSavedObjectsClient, + attachmentId: attachmentID, + }); + + await authorization.ensureAuthorized({ + entities: [{ owner: comment.attributes.owner, id: comment.id }], + operation: Operations.getComment, + }); + + return CommentResponseRt.encode(flattenCommentSavedObject(comment)); + } catch (error) { + throw createCaseError({ + message: `Failed to get comment case id: ${caseID} attachment id: ${attachmentID}: ${error}`, + error, + logger, + }); + } +} + +/** + * Retrieves all the attachments for a case. The `includeSubCaseComments` can be used to include the sub case comments for + * collections. If the entity is a sub case, pass in the subCaseID. + * + * @ignore + */ +export async function getAll( + { caseID, includeSubCaseComments, subCaseID }: GetAllArgs, + clientArgs: CasesClientArgs +): Promise { + const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + + try { + let comments: SavedObjectsFindResponse; + + if ( + !ENABLE_CASE_CONNECTOR && + (subCaseID !== undefined || includeSubCaseComments !== undefined) + ) { + throw Boom.badRequest( + 'The sub case id and include sub case comments fields are not supported when the case connector feature is disabled' + ); + } + + const { filter, ensureSavedObjectsAreAuthorized } = await authorization.getAuthorizationFilter( + Operations.getAllComments + ); + + if (subCaseID) { + comments = await caseService.getAllSubCaseComments({ + unsecuredSavedObjectsClient, + id: subCaseID, + options: { + filter, + sortField: defaultSortField, + }, + }); + } else { + comments = await caseService.getAllCaseComments({ + unsecuredSavedObjectsClient, + id: caseID, + includeSubCaseComments, + options: { + filter, + sortField: defaultSortField, + }, + }); + } + + ensureSavedObjectsAreAuthorized( + comments.saved_objects.map((comment) => ({ id: comment.id, owner: comment.attributes.owner })) + ); + + return AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)); + } catch (error) { + throw createCaseError({ + message: `Failed to get all comments case id: ${caseID} include sub case comments: ${includeSubCaseComments} sub case id: ${subCaseID}: ${error}`, + error, + logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/attachments/update.ts b/x-pack/plugins/cases/server/client/attachments/update.ts new file mode 100644 index 0000000000000..c0566ff646814 --- /dev/null +++ b/x-pack/plugins/cases/server/client/attachments/update.ts @@ -0,0 +1,207 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { pick } from 'lodash/fp'; +import Boom from '@hapi/boom'; + +import { SavedObjectsClientContract, Logger } from 'kibana/server'; +import { checkEnabledCaseConnectorOrThrow, CommentableCase } from '../../common'; +import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../common/constants'; +import { AttachmentService, CasesService } from '../../services'; +import { CaseResponse, CommentPatchRequest } from '../../../common/api'; +import { CasesClientArgs } from '..'; +import { decodeCommentRequest } from '../utils'; +import { createCaseError } from '../../common/error'; +import { Operations } from '../../authorization'; + +/** + * Parameters for updating a single attachment + */ +export interface UpdateArgs { + /** + * The ID of the case that is associated with this attachment + */ + caseID: string; + /** + * The full attachment request with the fields updated with appropriate values + */ + updateRequest: CommentPatchRequest; + /** + * The ID of a sub case, if specified a sub case will be searched for to perform the attachment update instead of on a case + */ + subCaseID?: string; +} + +interface CombinedCaseParams { + attachmentService: AttachmentService; + caseService: CasesService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + caseID: string; + logger: Logger; + subCaseId?: string; +} + +async function getCommentableCase({ + attachmentService, + caseService, + unsecuredSavedObjectsClient, + caseID, + subCaseId, + logger, +}: CombinedCaseParams) { + if (subCaseId) { + const [caseInfo, subCase] = await Promise.all([ + caseService.getCase({ + unsecuredSavedObjectsClient, + id: caseID, + }), + caseService.getSubCase({ + unsecuredSavedObjectsClient, + id: subCaseId, + }), + ]); + return new CommentableCase({ + attachmentService, + caseService, + collection: caseInfo, + subCase, + unsecuredSavedObjectsClient, + logger, + }); + } else { + const caseInfo = await caseService.getCase({ + unsecuredSavedObjectsClient, + id: caseID, + }); + return new CommentableCase({ + attachmentService, + caseService, + collection: caseInfo, + unsecuredSavedObjectsClient, + logger, + }); + } +} + +/** + * Update an attachment. + * + * @ignore + */ +export async function update( + { caseID, subCaseID, updateRequest: queryParams }: UpdateArgs, + clientArgs: CasesClientArgs +): Promise { + const { + attachmentService, + caseService, + unsecuredSavedObjectsClient, + logger, + user, + userActionService, + authorization, + } = clientArgs; + + try { + checkEnabledCaseConnectorOrThrow(subCaseID); + + const { + id: queryCommentId, + version: queryCommentVersion, + ...queryRestAttributes + } = queryParams; + + decodeCommentRequest(queryRestAttributes); + + const commentableCase = await getCommentableCase({ + attachmentService, + caseService, + unsecuredSavedObjectsClient, + caseID, + subCaseId: subCaseID, + logger, + }); + + const myComment = await attachmentService.get({ + unsecuredSavedObjectsClient, + attachmentId: queryCommentId, + }); + + if (myComment == null) { + throw Boom.notFound(`This comment ${queryCommentId} does not exist anymore.`); + } + + await authorization.ensureAuthorized({ + entities: [{ owner: myComment.attributes.owner, id: myComment.id }], + operation: Operations.updateComment, + }); + + if (myComment.attributes.type !== queryRestAttributes.type) { + throw Boom.badRequest(`You cannot change the type of the comment.`); + } + + if (myComment.attributes.owner !== queryRestAttributes.owner) { + throw Boom.badRequest(`You cannot change the owner of the comment.`); + } + + const saveObjType = subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + + const caseRef = myComment.references.find((c) => c.type === saveObjType); + if (caseRef == null || (caseRef != null && caseRef.id !== commentableCase.id)) { + throw Boom.notFound( + `This comment ${queryCommentId} does not exist in ${commentableCase.id}).` + ); + } + + if (queryCommentVersion !== myComment.version) { + throw Boom.conflict( + 'This case has been updated. Please refresh before saving additional updates.' + ); + } + + const updatedDate = new Date().toISOString(); + const { + comment: updatedComment, + commentableCase: updatedCase, + } = await commentableCase.updateComment({ + updateRequest: queryParams, + updatedAt: updatedDate, + user, + }); + + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, + actions: [ + buildCommentUserActionItem({ + action: 'update', + actionAt: updatedDate, + actionBy: user, + caseId: caseID, + subCaseId: subCaseID, + commentId: updatedComment.id, + fields: ['comment'], + newValue: JSON.stringify(queryRestAttributes), + oldValue: JSON.stringify( + // We are interested only in ContextBasicRt attributes + // myComment.attribute contains also CommentAttributesBasicRt attributes + pick(Object.keys(queryRestAttributes), myComment.attributes) + ), + owner: myComment.attributes.owner, + }), + ], + }); + + return await updatedCase.encode(); + } catch (error) { + throw createCaseError({ + message: `Failed to patch comment case id: ${caseID} sub case id: ${subCaseID}: ${error}`, + error, + logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts new file mode 100644 index 0000000000000..20670f331443b --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/client.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { + CasePostRequest, + CasesPatchRequest, + CasesFindRequest, + User, + AllTagsFindRequest, + AllReportersFindRequest, +} from '../../../common/api'; +import { CasesClient } from '../client'; +import { CasesClientInternal } from '../client_internal'; +import { + ICasePostRequest, + ICaseResponse, + ICasesFindRequest, + ICasesFindResponse, + ICasesPatchRequest, + ICasesResponse, +} from '../typedoc_interfaces'; +import { CasesClientArgs } from '../types'; +import { create } from './create'; +import { deleteCases } from './delete'; +import { find } from './find'; +import { + CaseIDsByAlertIDParams, + get, + getCaseIDsByAlertID, + GetParams, + getReporters, + getTags, +} from './get'; +import { push, PushParams } from './push'; +import { update } from './update'; + +/** + * API for interacting with the cases entities. + */ +export interface CasesSubClient { + /** + * Creates a case. + */ + create(data: ICasePostRequest): Promise; + /** + * Returns cases that match the search criteria. + * + * If the `owner` field is left empty then all the cases that the user has access to will be returned. + */ + find(params: ICasesFindRequest): Promise; + /** + * Retrieves a single case with the specified ID. + */ + get(params: GetParams): Promise; + /** + * Pushes a specific case to an external system. + */ + push(args: PushParams): Promise; + /** + * Update the specified cases with the passed in values. + */ + update(cases: ICasesPatchRequest): Promise; + /** + * Delete a case and all its comments. + * + * @params ids an array of case IDs to delete + */ + delete(ids: string[]): Promise; + /** + * Retrieves all the tags across all cases the user making the request has access to. + */ + getTags(params: AllTagsFindRequest): Promise; + /** + * Retrieves all the reporters across all accessible cases. + */ + getReporters(params: AllReportersFindRequest): Promise; + /** + * Retrieves the case IDs given a single alert ID + */ + getCaseIDsByAlertID(params: CaseIDsByAlertIDParams): Promise; +} + +/** + * Creates the interface for CRUD on cases objects. + * + * @ignore + */ +export const createCasesSubClient = ( + clientArgs: CasesClientArgs, + casesClient: CasesClient, + casesClientInternal: CasesClientInternal +): CasesSubClient => { + const casesSubClient: CasesSubClient = { + create: (data: CasePostRequest) => create(data, clientArgs), + find: (params: CasesFindRequest) => find(params, clientArgs), + get: (params: GetParams) => get(params, clientArgs), + push: (params: PushParams) => push(params, clientArgs, casesClient, casesClientInternal), + update: (cases: CasesPatchRequest) => update(cases, clientArgs, casesClientInternal), + delete: (ids: string[]) => deleteCases(ids, clientArgs), + getTags: (params: AllTagsFindRequest) => getTags(params, clientArgs), + getReporters: (params: AllReportersFindRequest) => getReporters(params, clientArgs), + getCaseIDsByAlertID: (params: CaseIDsByAlertIDParams) => + getCaseIDsByAlertID(params, clientArgs), + }; + + return Object.freeze(casesSubClient); +}; diff --git a/x-pack/plugins/cases/server/client/cases/create.test.ts b/x-pack/plugins/cases/server/client/cases/create.test.ts deleted file mode 100644 index 9cbe2a448d3b4..0000000000000 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ /dev/null @@ -1,464 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { ConnectorTypes, CaseStatuses, CaseType, CasesClientPostRequest } from '../../../common'; -import { isCaseError } from '../../common/error'; - -import { - createMockSavedObjectsRepository, - mockCaseConfigure, - mockCases, -} from '../../routes/api/__fixtures__'; -import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; - -describe('create', () => { - beforeEach(async () => { - jest.restoreAllMocks(); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), - })); - }); - - describe('happy path', () => { - test('it creates the case correctly', async () => { - const postCase: CasesClientPostRequest = { - description: 'This is a brand new case of a bad meanie defacing data', - title: 'Super Bad Security Issue', - tags: ['defacement'], - type: CaseType.individual, - connector: { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - settings: { - syncAlerts: true, - }, - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseConfigureSavedObject: mockCaseConfigure, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.create(postCase); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "Jira", - "type": ".jira", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-it", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", - } - `); - - expect( - casesClient.services.userActionService.postUserActions.mock.calls[0][0].actions - // using a snapshot here so we don't have to update the text field manually each time it changes - ).toMatchInlineSnapshot(` - Array [ - Object { - "attributes": Object { - "action": "create", - "action_at": "2019-11-25T21:54:48.952Z", - "action_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "action_field": Array [ - "description", - "status", - "tags", - "title", - "connector", - "settings", - ], - "new_value": "{\\"type\\":\\"individual\\",\\"description\\":\\"This is a brand new case of a bad meanie defacing data\\",\\"title\\":\\"Super Bad Security Issue\\",\\"tags\\":[\\"defacement\\"],\\"connector\\":{\\"id\\":\\"123\\",\\"name\\":\\"Jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"Task\\",\\"priority\\":\\"High\\",\\"parent\\":null}},\\"settings\\":{\\"syncAlerts\\":true}}", - "old_value": null, - }, - "references": Array [ - Object { - "id": "mock-it", - "name": "associated-cases", - "type": "cases", - }, - ], - }, - ] - `); - }); - - test('it creates the case without connector in the configuration', async () => { - const postCase: CasesClientPostRequest = { - description: 'This is a brand new case of a bad meanie defacing data', - title: 'Super Bad Security Issue', - tags: ['defacement'], - type: CaseType.individual, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - settings: { - syncAlerts: true, - }, - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.create(postCase); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-it", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", - } - `); - }); - - test('Allow user to create case without authentication', async () => { - const postCase: CasesClientPostRequest = { - description: 'This is a brand new case of a bad meanie defacing data', - title: 'Super Bad Security Issue', - tags: ['defacement'], - type: CaseType.individual, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - settings: { - syncAlerts: true, - }, - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ - savedObjectsClient, - badAuth: true, - }); - const res = await casesClient.client.create(postCase); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-it", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", - } - `); - }); - }); - - describe('unhappy path', () => { - test('it throws when missing title', async () => { - expect.assertions(3); - const postCase = { - description: 'This is a brand new case of a bad meanie defacing data', - tags: ['defacement'], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return ( - casesClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }) - ); - }); - - test('it throws when missing description', async () => { - expect.assertions(3); - const postCase = { - title: 'a title', - tags: ['defacement'], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return ( - casesClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }) - ); - }); - - test('it throws when missing tags', async () => { - expect.assertions(3); - const postCase = { - title: 'a title', - description: 'This is a brand new case of a bad meanie defacing data', - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return ( - casesClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }) - ); - }); - - test('it throws when missing connector ', async () => { - expect.assertions(3); - const postCase = { - title: 'a title', - description: 'This is a brand new case of a bad meanie defacing data', - tags: ['defacement'], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return ( - casesClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }) - ); - }); - - test('it throws when connector missing the right fields', async () => { - expect.assertions(3); - const postCase = { - title: 'a title', - description: 'This is a brand new case of a bad meanie defacing data', - tags: ['defacement'], - connector: { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: {}, - }, - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return ( - casesClient.client - // @ts-expect-error - .create({ theCase: postCase }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }) - ); - }); - - test('it throws if you passing status for a new case', async () => { - expect.assertions(3); - const postCase = { - title: 'a title', - description: 'This is a brand new case of a bad meanie defacing data', - tags: ['defacement'], - type: CaseType.individual, - status: CaseStatuses.closed, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - settings: { - syncAlerts: true, - }, - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return casesClient.client.create(postCase).catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); - }); - - it(`Returns an error if postNewCase throws`, async () => { - const postCase: CasesClientPostRequest = { - description: 'Throw an error', - title: 'Super Bad Security Issue', - tags: ['error'], - type: CaseType.individual, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - settings: { - syncAlerts: true, - }, - }; - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - - return casesClient.client.create(postCase).catch((e) => { - expect(e).not.toBeNull(); - expect(isCaseError(e)).toBeTruthy(); - const boomErr = e.boomify(); - expect(boomErr.isBoom).toBe(true); - expect(boomErr.output.statusCode).toBe(400); - }); - }); - }); -}); diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index fae60743073c1..879edd5eb1b5c 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -10,8 +10,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObjectsClientContract, Logger } from 'src/core/server'; -import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; +import { SavedObjectsUtils } from '../../../../../../src/core/server'; import { throwErrors, @@ -21,46 +20,42 @@ import { CasesClientPostRequestRt, CasePostRequest, CaseType, - User, + OWNER_FIELD, } from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; -import { - getConnectorFromConfiguration, - transformCaseConnectorToEsConnector, -} from '../../routes/api/cases/helpers'; +import { getConnectorFromConfiguration } from '../utils'; -import { - CaseConfigureServiceSetup, - CaseServiceSetup, - CaseUserActionServiceSetup, -} from '../../services'; import { createCaseError } from '../../common/error'; +import { Operations } from '../../authorization'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; - -interface CreateCaseArgs { - caseConfigureService: CaseConfigureServiceSetup; - caseService: CaseServiceSetup; - user: User; - savedObjectsClient: SavedObjectsClientContract; - userActionService: CaseUserActionServiceSetup; - theCase: CasePostRequest; - logger: Logger; -} +import { + flattenCaseSavedObject, + transformCaseConnectorToEsConnector, + transformNewCase, +} from '../../common'; +import { CasesClientArgs } from '..'; /** * Creates a new case. + * + * @ignore */ -export const create = async ({ - savedObjectsClient, - caseService, - caseConfigureService, - userActionService, - user, - theCase, - logger, -}: CreateCaseArgs): Promise => { +export const create = async ( + data: CasePostRequest, + clientArgs: CasesClientArgs +): Promise => { + const { + unsecuredSavedObjectsClient, + caseService, + caseConfigureService, + userActionService, + user, + logger, + authorization: auth, + } = clientArgs; + // default to an individual case if the type is not defined. - const { type = CaseType.individual, ...nonTypeCaseFields } = theCase; + const { type = CaseType.individual, ...nonTypeCaseFields } = data; if (!ENABLE_CASE_CONNECTOR && type === CaseType.collection) { throw Boom.badRequest( @@ -78,14 +73,23 @@ export const create = async ({ ); try { + const savedObjectID = SavedObjectsUtils.generateId(); + + await auth.ensureAuthorized({ + operation: Operations.createCase, + entities: [{ owner: query.owner, id: savedObjectID }], + }); + // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = user; const createdDate = new Date().toISOString(); - const myCaseConfigure = await caseConfigureService.find({ client: savedObjectsClient }); + const myCaseConfigure = await caseConfigureService.find({ + unsecuredSavedObjectsClient, + }); const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); const newCase = await caseService.postNewCase({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, attributes: transformNewCase({ createdDate, newCase: query, @@ -94,18 +98,20 @@ export const create = async ({ email, connector: transformCaseConnectorToEsConnector(query.connector ?? caseConfigureConnector), }), + id: savedObjectID, }); - await userActionService.postUserActions({ - client: savedObjectsClient, + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, actions: [ buildCaseUserActionItem({ action: 'create', actionAt: createdDate, actionBy: { username, full_name, email }, caseId: newCase.id, - fields: ['description', 'status', 'tags', 'title', 'connector', 'settings'], + fields: ['description', 'status', 'tags', 'title', 'connector', 'settings', OWNER_FIELD], newValue: JSON.stringify(query), + owner: newCase.attributes.owner, }), ], }); diff --git a/x-pack/plugins/cases/server/client/cases/delete.ts b/x-pack/plugins/cases/server/client/cases/delete.ts new file mode 100644 index 0000000000000..b66abc6cc7be4 --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/delete.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 { Boom } from '@hapi/boom'; +import { SavedObjectsClientContract } from 'kibana/server'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; +import { CasesClientArgs } from '..'; +import { createCaseError } from '../../common/error'; +import { AttachmentService, CasesService } from '../../services'; +import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; +import { Operations, OwnerEntity } from '../../authorization'; +import { OWNER_FIELD } from '../../../common/api'; + +async function deleteSubCases({ + attachmentService, + caseService, + unsecuredSavedObjectsClient, + caseIds, +}: { + attachmentService: AttachmentService; + caseService: CasesService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + caseIds: string[]; +}) { + const subCasesForCaseIds = await caseService.findSubCasesByCaseId({ + unsecuredSavedObjectsClient, + ids: caseIds, + }); + + const subCaseIDs = subCasesForCaseIds.saved_objects.map((subCase) => subCase.id); + const commentsForSubCases = await caseService.getAllSubCaseComments({ + unsecuredSavedObjectsClient, + id: subCaseIDs, + }); + + // This shouldn't actually delete anything because all the comments should be deleted when comments are deleted + // per case ID + await Promise.all( + commentsForSubCases.saved_objects.map((commentSO) => + attachmentService.delete({ unsecuredSavedObjectsClient, attachmentId: commentSO.id }) + ) + ); + + await Promise.all( + subCasesForCaseIds.saved_objects.map((subCaseSO) => + caseService.deleteSubCase(unsecuredSavedObjectsClient, subCaseSO.id) + ) + ); +} + +/** + * Deletes the specified cases and their attachments. + * + * @ignore + */ +export async function deleteCases(ids: string[], clientArgs: CasesClientArgs): Promise { + const { + unsecuredSavedObjectsClient, + caseService, + attachmentService, + user, + userActionService, + logger, + authorization, + } = clientArgs; + try { + const cases = await caseService.getCases({ unsecuredSavedObjectsClient, caseIds: ids }); + const entities = new Map(); + + for (const theCase of cases.saved_objects) { + // bulkGet can return an error. + if (theCase.error != null) { + throw createCaseError({ + message: `Failed to delete cases ids: ${JSON.stringify(ids)}: ${theCase.error.error}`, + error: new Boom(theCase.error.message, { statusCode: theCase.error.statusCode }), + logger, + }); + } + entities.set(theCase.id, { id: theCase.id, owner: theCase.attributes.owner }); + } + + await authorization.ensureAuthorized({ + operation: Operations.deleteCase, + entities: Array.from(entities.values()), + }); + + await Promise.all( + ids.map((id) => + caseService.deleteCase({ + unsecuredSavedObjectsClient, + id, + }) + ) + ); + + const comments = await Promise.all( + ids.map((id) => + caseService.getAllCaseComments({ + unsecuredSavedObjectsClient, + id, + }) + ) + ); + + if (comments.some((c) => c.saved_objects.length > 0)) { + await Promise.all( + comments.map((c) => + Promise.all( + c.saved_objects.map(({ id }) => + attachmentService.delete({ + unsecuredSavedObjectsClient, + attachmentId: id, + }) + ) + ) + ) + ); + } + + if (ENABLE_CASE_CONNECTOR) { + await deleteSubCases({ + attachmentService, + caseService, + unsecuredSavedObjectsClient, + caseIds: ids, + }); + } + + const deleteDate = new Date().toISOString(); + + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, + actions: cases.saved_objects.map((caseInfo) => + buildCaseUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: user, + caseId: caseInfo.id, + fields: [ + 'description', + 'status', + 'tags', + 'title', + 'connector', + 'settings', + OWNER_FIELD, + 'comment', + ...(ENABLE_CASE_CONNECTOR ? ['sub_case'] : []), + ], + owner: caseInfo.attributes.owner, + }) + ), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to delete cases ids: ${JSON.stringify(ids)}: ${error}`, + error, + logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/cases/find.ts b/x-pack/plugins/cases/server/client/cases/find.ts new file mode 100644 index 0000000000000..3b4efe78f642b --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/find.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + CasesFindResponse, + CasesFindRequest, + CasesFindRequestRt, + throwErrors, + caseStatuses, + CasesFindResponseRt, + excess, +} from '../../../common/api'; + +import { createCaseError } from '../../common/error'; +import { constructQueryOptions } from '../utils'; +import { includeFieldsRequiredForAuthentication } from '../../authorization/utils'; +import { Operations } from '../../authorization'; +import { transformCases } from '../../common'; +import { CasesClientArgs } from '..'; + +/** + * Retrieves a case and optionally its comments and sub case comments. + * + * @ignore + */ +export const find = async ( + params: CasesFindRequest, + clientArgs: CasesClientArgs +): Promise => { + const { unsecuredSavedObjectsClient, caseService, authorization, logger } = clientArgs; + + try { + const queryParams = pipe( + excess(CasesFindRequestRt).decode(params), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { + filter: authorizationFilter, + ensureSavedObjectsAreAuthorized, + } = await authorization.getAuthorizationFilter(Operations.findCases); + + const queryArgs = { + tags: queryParams.tags, + reporters: queryParams.reporters, + sortByField: queryParams.sortField, + status: queryParams.status, + caseType: queryParams.type, + owner: queryParams.owner, + }; + + const caseQueries = constructQueryOptions({ ...queryArgs, authorizationFilter }); + const cases = await caseService.findCasesGroupedByID({ + unsecuredSavedObjectsClient, + caseOptions: { + ...queryParams, + ...caseQueries.case, + searchFields: + queryParams.searchFields != null + ? Array.isArray(queryParams.searchFields) + ? queryParams.searchFields + : [queryParams.searchFields] + : queryParams.searchFields, + fields: includeFieldsRequiredForAuthentication(queryParams.fields), + }, + subCaseOptions: caseQueries.subCase, + }); + + ensureSavedObjectsAreAuthorized([...cases.casesMap.values()]); + + const [openCases, inProgressCases, closedCases] = await Promise.all([ + ...caseStatuses.map((status) => { + const statusQuery = constructQueryOptions({ ...queryArgs, status, authorizationFilter }); + return caseService.findCaseStatusStats({ + unsecuredSavedObjectsClient, + caseOptions: statusQuery.case, + subCaseOptions: statusQuery.subCase, + ensureSavedObjectsAreAuthorized, + }); + }), + ]); + + return CasesFindResponseRt.encode( + transformCases({ + casesMap: cases.casesMap, + page: cases.page, + perPage: cases.perPage, + total: cases.total, + countOpenCases: openCases, + countInProgressCases: inProgressCases, + countClosedCases: closedCases, + }) + ); + } catch (error) { + throw createCaseError({ + message: `Failed to find cases: ${JSON.stringify(params)}: ${error}`, + error, + logger, + }); + } +}; diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index 08fa96a3bbe6f..7a8100ad60ff3 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -4,57 +4,168 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; -import { SavedObjectsClientContract, Logger, SavedObject } from 'kibana/server'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; -import { CaseResponseRt, CaseResponse, ESCaseAttributes } from '../../../common'; -import { CaseServiceSetup } from '../../services'; -import { countAlertsForID } from '../../common'; +import { SavedObject } from 'kibana/server'; +import { + CaseResponseRt, + CaseResponse, + ESCaseAttributes, + User, + UsersRt, + AllTagsFindRequest, + AllTagsFindRequestRt, + excess, + throwErrors, + AllReportersFindRequestRt, + AllReportersFindRequest, + CasesByAlertIDRequest, + CasesByAlertIDRequestRt, +} from '../../../common/api'; +import { countAlertsForID, flattenCaseSavedObject } from '../../common'; import { createCaseError } from '../../common/error'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; +import { CasesClientArgs } from '..'; +import { Operations } from '../../authorization'; +import { combineAuthorizedAndOwnerFilter } from '../utils'; +import { CasesService } from '../../services'; -interface GetParams { - savedObjectsClient: SavedObjectsClientContract; - caseService: CaseServiceSetup; +/** + * Parameters for finding cases IDs using an alert ID + */ +export interface CaseIDsByAlertIDParams { + /** + * The alert ID to search for + */ + alertID: string; + /** + * The filtering options when searching for associated cases. + */ + options: CasesByAlertIDRequest; +} + +/** + * Case Client wrapper function for retrieving the case IDs that have a particular alert ID + * attached to them. This handles RBAC before calling the saved object API. + * + * @ignore + */ +export const getCaseIDsByAlertID = async ( + { alertID, options }: CaseIDsByAlertIDParams, + clientArgs: CasesClientArgs +): Promise => { + const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + + try { + const queryParams = pipe( + excess(CasesByAlertIDRequestRt).decode(options), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { + filter: authorizationFilter, + ensureSavedObjectsAreAuthorized, + } = await authorization.getAuthorizationFilter(Operations.getCaseIDsByAlertID); + + const filter = combineAuthorizedAndOwnerFilter( + queryParams.owner, + authorizationFilter, + Operations.getCaseIDsByAlertID.savedObjectType + ); + + const commentsWithAlert = await caseService.getCaseIdsByAlertId({ + unsecuredSavedObjectsClient, + alertId: alertID, + filter, + }); + + ensureSavedObjectsAreAuthorized( + commentsWithAlert.saved_objects.map((comment) => ({ + owner: comment.attributes.owner, + id: comment.id, + })) + ); + + return CasesService.getCaseIDsFromAlertAggs(commentsWithAlert); + } catch (error) { + throw createCaseError({ + message: `Failed to get case IDs using alert ID: ${alertID} options: ${JSON.stringify( + options + )}: ${error}`, + error, + logger, + }); + } +}; + +/** + * The parameters for retrieving a case + */ +export interface GetParams { + /** + * Case ID + */ id: string; + /** + * Whether to include the attachments for a case in the response + */ includeComments?: boolean; + /** + * Whether to include the attachments for all children of a case in the response + */ includeSubCaseComments?: boolean; - logger: Logger; } /** * Retrieves a case and optionally its comments and sub case comments. + * + * @ignore */ -export const get = async ({ - savedObjectsClient, - caseService, - id, - logger, - includeComments = false, - includeSubCaseComments = false, -}: GetParams): Promise => { +export const get = async ( + { id, includeComments, includeSubCaseComments }: GetParams, + clientArgs: CasesClientArgs +): Promise => { + const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + try { + if (!ENABLE_CASE_CONNECTOR && includeSubCaseComments) { + throw Boom.badRequest( + 'The `includeSubCaseComments` is not supported when the case connector feature is disabled' + ); + } + let theCase: SavedObject; let subCaseIds: string[] = []; if (ENABLE_CASE_CONNECTOR) { const [caseInfo, subCasesForCaseId] = await Promise.all([ caseService.getCase({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, id, }), - caseService.findSubCasesByCaseId({ client: savedObjectsClient, ids: [id] }), + caseService.findSubCasesByCaseId({ + unsecuredSavedObjectsClient, + ids: [id], + }), ]); theCase = caseInfo; subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id); } else { theCase = await caseService.getCase({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, id, }); } + await authorization.ensureAuthorized({ + operation: Operations.getCase, + entities: [{ owner: theCase.attributes.owner, id: theCase.id }], + }); + if (!includeComments) { return CaseResponseRt.encode( flattenCaseSavedObject({ @@ -63,8 +174,9 @@ export const get = async ({ }) ); } + const theComments = await caseService.getAllCaseComments({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, id, options: { sortField: 'created_at', @@ -86,3 +198,108 @@ export const get = async ({ throw createCaseError({ message: `Failed to get case id: ${id}: ${error}`, error, logger }); } }; + +/** + * Retrieves the tags from all the cases. + */ + +export async function getTags( + params: AllTagsFindRequest, + clientArgs: CasesClientArgs +): Promise { + const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + + try { + const queryParams = pipe( + excess(AllTagsFindRequestRt).decode(params), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { + filter: authorizationFilter, + ensureSavedObjectsAreAuthorized, + } = await authorization.getAuthorizationFilter(Operations.findCases); + + const filter = combineAuthorizedAndOwnerFilter(queryParams.owner, authorizationFilter); + + const cases = await caseService.getTags({ + unsecuredSavedObjectsClient, + filter, + }); + + const tags = new Set(); + const mappedCases: Array<{ + owner: string; + id: string; + }> = []; + + // Gather all necessary information in one pass + cases.saved_objects.forEach((theCase) => { + theCase.attributes.tags.forEach((tag) => tags.add(tag)); + mappedCases.push({ + id: theCase.id, + owner: theCase.attributes.owner, + }); + }); + + ensureSavedObjectsAreAuthorized(mappedCases); + + return [...tags.values()]; + } catch (error) { + throw createCaseError({ message: `Failed to get tags: ${error}`, error, logger }); + } +} + +/** + * Retrieves the reporters from all the cases. + */ +export async function getReporters( + params: AllReportersFindRequest, + clientArgs: CasesClientArgs +): Promise { + const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + + try { + const queryParams = pipe( + excess(AllReportersFindRequestRt).decode(params), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { + filter: authorizationFilter, + ensureSavedObjectsAreAuthorized, + } = await authorization.getAuthorizationFilter(Operations.getReporters); + + const filter = combineAuthorizedAndOwnerFilter(queryParams.owner, authorizationFilter); + + const cases = await caseService.getReporters({ + unsecuredSavedObjectsClient, + filter, + }); + + const reporters = new Map(); + const mappedCases: Array<{ + owner: string; + id: string; + }> = []; + + // Gather all necessary information in one pass + cases.saved_objects.forEach((theCase) => { + const user = theCase.attributes.created_by; + if (user.username != null) { + reporters.set(user.username, user); + } + + mappedCases.push({ + id: theCase.id, + owner: theCase.attributes.owner, + }); + }); + + ensureSavedObjectsAreAuthorized(mappedCases); + + return UsersRt.encode([...reporters.values()]); + } catch (error) { + throw createCaseError({ message: `Failed to get reporters: ${error}`, error, logger }); + } +} diff --git a/x-pack/plugins/cases/server/client/cases/mock.ts b/x-pack/plugins/cases/server/client/cases/mock.ts index 0e589b901c8d1..23db57c6d3097 100644 --- a/x-pack/plugins/cases/server/client/cases/mock.ts +++ b/x-pack/plugins/cases/server/client/cases/mock.ts @@ -12,6 +12,7 @@ import { CaseUserActionsResponse, AssociationType, CommentResponseAlertsType, + SECURITY_SOLUTION_OWNER, } from '../../../common'; import { BasicParams } from './types'; @@ -39,6 +40,7 @@ export const comment: CommentResponse = { email: 'testemail@elastic.co', username: 'elastic', }, + owner: SECURITY_SOLUTION_OWNER, pushed_at: null, pushed_by: null, updated_at: '2019-11-25T21:55:00.177Z', @@ -66,6 +68,7 @@ export const commentAlert: CommentResponse = { email: 'testemail@elastic.co', username: 'elastic', }, + owner: SECURITY_SOLUTION_OWNER, pushed_at: null, pushed_by: null, updated_at: '2019-11-25T21:55:00.177Z', @@ -83,6 +86,7 @@ export const commentAlertMultipleIds: CommentResponseAlertsType = { alertId: ['alert-id-1', 'alert-id-2'], index: 'alert-index-1', type: CommentType.alert as const, + owner: SECURITY_SOLUTION_OWNER, }; export const commentGeneratedAlert: CommentResponseAlertsType = { @@ -132,6 +136,7 @@ export const userActions: CaseUserActionsResponse = [ action_id: 'fd830c60-6646-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: null, + owner: SECURITY_SOLUTION_OWNER, }, { action_field: ['pushed'], @@ -148,6 +153,7 @@ export const userActions: CaseUserActionsResponse = [ action_id: '0a801750-6647-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: null, + owner: SECURITY_SOLUTION_OWNER, }, { action_field: ['comment'], @@ -163,6 +169,7 @@ export const userActions: CaseUserActionsResponse = [ action_id: '7373eb60-6647-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: 'comment-alert-1', + owner: SECURITY_SOLUTION_OWNER, }, { action_field: ['comment'], @@ -178,6 +185,7 @@ export const userActions: CaseUserActionsResponse = [ action_id: '7abc6410-6647-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: 'comment-alert-2', + owner: SECURITY_SOLUTION_OWNER, }, { action_field: ['pushed'], @@ -194,6 +202,7 @@ export const userActions: CaseUserActionsResponse = [ action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: null, + owner: SECURITY_SOLUTION_OWNER, }, { action_field: ['comment'], @@ -209,5 +218,6 @@ export const userActions: CaseUserActionsResponse = [ action_id: '0818e5e0-6648-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: 'comment-user-1', + owner: SECURITY_SOLUTION_OWNER, }, ]; diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index 92a9d2910d4a3..dd527122d0616 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -6,16 +6,7 @@ */ import Boom from '@hapi/boom'; -import { - SavedObjectsBulkUpdateResponse, - SavedObjectsClientContract, - SavedObjectsUpdateResponse, - Logger, - SavedObjectsFindResponse, - SavedObject, -} from 'kibana/server'; -import { ActionResult, ActionsClient } from '../../../../actions/server'; -import { flattenCaseSavedObject, getAlertInfoFromComments } from '../../routes/api/utils'; +import { SavedObjectsFindResponse, SavedObject } from 'kibana/server'; import { ActionConnector, @@ -24,23 +15,16 @@ import { CaseStatuses, ExternalServiceResponse, ESCaseAttributes, - CommentAttributes, - CaseUserActionsResponse, - User, ESCasesConfigureAttributes, CaseType, } from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { createIncident, getCommentContextFromAttributes } from './utils'; -import { - CaseConfigureServiceSetup, - CaseServiceSetup, - CaseUserActionServiceSetup, -} from '../../services'; -import { CasesClientHandler } from '../client'; -import { createCaseError } from '../../common/error'; +import { createCaseError, flattenCaseSavedObject, getAlertInfoFromComments } from '../../common'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; +import { CasesClient, CasesClientArgs, CasesClientInternal } from '..'; +import { Operations } from '../../authorization'; /** * Returns true if the case should be closed based on the configuration settings and whether the case @@ -58,130 +42,116 @@ function shouldCloseByPush( ); } -interface PushParams { - savedObjectsClient: SavedObjectsClientContract; - caseService: CaseServiceSetup; - caseConfigureService: CaseConfigureServiceSetup; - userActionService: CaseUserActionServiceSetup; - user: User; +/** + * Parameters for pushing a case to an external system + */ +export interface PushParams { + /** + * The ID of a case + */ caseId: string; + /** + * The ID of an external system to push to + */ connectorId: string; - casesClient: CasesClientHandler; - actionsClient: ActionsClient; - logger: Logger; } -export const push = async ({ - savedObjectsClient, - caseService, - caseConfigureService, - userActionService, - casesClient, - actionsClient, - connectorId, - caseId, - user, - logger, -}: PushParams): Promise => { - /* Start of push to external service */ - let theCase: CaseResponse; - let connector: ActionResult; - let userActions: CaseUserActionsResponse; - let alerts; - let connectorMappings; - let externalServiceIncident; +/** + * Push a case to an external service. + * + * @ignore + */ +export const push = async ( + { connectorId, caseId }: PushParams, + clientArgs: CasesClientArgs, + casesClient: CasesClient, + casesClientInternal: CasesClientInternal +): Promise => { + const { + unsecuredSavedObjectsClient, + attachmentService, + caseService, + caseConfigureService, + userActionService, + actionsClient, + user, + logger, + authorization, + } = clientArgs; try { - [theCase, connector, userActions] = await Promise.all([ - casesClient.get({ + /* Start of push to external service */ + const [theCase, connector, userActions] = await Promise.all([ + casesClient.cases.get({ id: caseId, includeComments: true, includeSubCaseComments: ENABLE_CASE_CONNECTOR, }), actionsClient.get({ id: connectorId }), - casesClient.getUserActions({ caseId }), + casesClient.userActions.getAll({ caseId }), ]); - } catch (e) { - const message = `Error getting case and/or connector and/or user actions: ${e.message}`; - throw createCaseError({ message, error: e, logger }); - } - // We need to change the logic when we support subcases - if (theCase?.status === CaseStatuses.closed) { - throw Boom.conflict( - `This case ${theCase.title} is closed. You can not pushed if the case is closed.` - ); - } + await authorization.ensureAuthorized({ + entities: [{ owner: theCase.owner, id: caseId }], + operation: Operations.pushCase, + }); - const alertsInfo = getAlertInfoFromComments(theCase?.comments); + // We need to change the logic when we support subcases + if (theCase?.status === CaseStatuses.closed) { + throw Boom.conflict( + `The ${theCase.title} case is closed. Pushing a closed case is not allowed.` + ); + } - try { - alerts = await casesClient.getAlerts({ + const alertsInfo = getAlertInfoFromComments(theCase?.comments); + + const alerts = await casesClientInternal.alerts.get({ alertsInfo, }); - } catch (e) { - throw createCaseError({ - message: `Error getting alerts for case with id ${theCase.id}: ${e.message}`, - logger, - error: e, - }); - } - try { - connectorMappings = await casesClient.getMappings({ - actionsClient, + const connectorMappings = await casesClientInternal.configuration.getMappings({ connectorId: connector.id, connectorType: connector.actionTypeId, }); - } catch (e) { - const message = `Error getting mapping for connector with id ${connector.id}: ${e.message}`; - throw createCaseError({ message, error: e, logger }); - } - try { - externalServiceIncident = await createIncident({ + if (connectorMappings.length === 0) { + throw new Error('Connector mapping has not been created'); + } + + const externalServiceIncident = await createIncident({ actionsClient, theCase, userActions, connector: connector as ActionConnector, - mappings: connectorMappings, + mappings: connectorMappings[0].attributes.mappings, alerts, }); - } catch (e) { - const message = `Error creating incident for case with id ${theCase.id}: ${e.message}`; - throw createCaseError({ error: e, message, logger }); - } - - const pushRes = await actionsClient.execute({ - actionId: connector?.id ?? '', - params: { - subAction: 'pushToService', - subActionParams: externalServiceIncident, - }, - }); - if (pushRes.status === 'error') { - throw Boom.failedDependency( - pushRes.serviceMessage ?? pushRes.message ?? 'Error pushing to service' - ); - } + const pushRes = await actionsClient.execute({ + actionId: connector?.id ?? '', + params: { + subAction: 'pushToService', + subActionParams: externalServiceIncident, + }, + }); - /* End of push to external service */ + if (pushRes.status === 'error') { + throw Boom.failedDependency( + pushRes.serviceMessage ?? pushRes.message ?? 'Error pushing to service' + ); + } - /* Start of update case with push information */ - let myCase; - let myCaseConfigure; - let comments; + /* End of push to external service */ - try { - [myCase, myCaseConfigure, comments] = await Promise.all([ + /* Start of update case with push information */ + const [myCase, myCaseConfigure, comments] = await Promise.all([ caseService.getCase({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, id: caseId, }), - caseConfigureService.find({ client: savedObjectsClient }), + caseConfigureService.find({ unsecuredSavedObjectsClient }), caseService.getAllCaseComments({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, id: caseId, options: { fields: [], @@ -191,35 +161,27 @@ export const push = async ({ includeSubCaseComments: ENABLE_CASE_CONNECTOR, }), ]); - } catch (e) { - const message = `Error getting user and/or case and/or case configuration and/or case comments: ${e.message}`; - throw createCaseError({ error: e, message, logger }); - } - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = user; - const pushedDate = new Date().toISOString(); - const externalServiceResponse = pushRes.data as ExternalServiceResponse; + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const pushedDate = new Date().toISOString(); + const externalServiceResponse = pushRes.data as ExternalServiceResponse; - const externalService = { - pushed_at: pushedDate, - pushed_by: { username, full_name, email }, - connector_id: connector.id, - connector_name: connector.name, - external_id: externalServiceResponse.id, - external_title: externalServiceResponse.title, - external_url: externalServiceResponse.url, - }; + const externalService = { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + connector_id: connector.id, + connector_name: connector.name, + external_id: externalServiceResponse.id, + external_title: externalServiceResponse.title, + external_url: externalServiceResponse.url, + }; - let updatedCase: SavedObjectsUpdateResponse; - let updatedComments: SavedObjectsBulkUpdateResponse; + const shouldMarkAsClosed = shouldCloseByPush(myCaseConfigure, myCase); - const shouldMarkAsClosed = shouldCloseByPush(myCaseConfigure, myCase); - - try { - [updatedCase, updatedComments] = await Promise.all([ + const [updatedCase, updatedComments] = await Promise.all([ caseService.patchCase({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, caseId, updatedAttributes: { ...(shouldMarkAsClosed @@ -236,12 +198,12 @@ export const push = async ({ version: myCase.version, }), - caseService.patchComments({ - client: savedObjectsClient, + attachmentService.bulkUpdate({ + unsecuredSavedObjectsClient, comments: comments.saved_objects .filter((comment) => comment.attributes.pushed_at == null) .map((comment) => ({ - commentId: comment.id, + attachmentId: comment.id, updatedAttributes: { pushed_at: pushedDate, pushed_by: { username, full_name, email }, @@ -250,8 +212,8 @@ export const push = async ({ })), }), - userActionService.postUserActions({ - client: savedObjectsClient, + userActionService.bulkCreate({ + unsecuredSavedObjectsClient, actions: [ ...(shouldMarkAsClosed ? [ @@ -263,6 +225,7 @@ export const push = async ({ fields: ['status'], newValue: CaseStatuses.closed, oldValue: myCase.attributes.status, + owner: myCase.attributes.owner, }), ] : []), @@ -273,38 +236,39 @@ export const push = async ({ caseId, fields: ['pushed'], newValue: JSON.stringify(externalService), + owner: myCase.attributes.owner, }), ], }), ]); - } catch (e) { - const message = `Error updating case and/or comments and/or creating user action: ${e.message}`; - throw createCaseError({ error: e, message, logger }); - } - /* End of update case with push information */ - return CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - }, - comments: comments.saved_objects.map((origComment) => { - const updatedComment = updatedComments.saved_objects.find((c) => c.id === origComment.id); - return { - ...origComment, - ...updatedComment, - attributes: { - ...origComment.attributes, - ...updatedComment?.attributes, - ...getCommentContextFromAttributes(origComment.attributes), - }, - version: updatedComment?.version ?? origComment.version, - references: origComment?.references ?? [], - }; - }), - }) - ); + /* End of update case with push information */ + + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + }, + comments: comments.saved_objects.map((origComment) => { + const updatedComment = updatedComments.saved_objects.find((c) => c.id === origComment.id); + return { + ...origComment, + ...updatedComment, + attributes: { + ...origComment.attributes, + ...updatedComment?.attributes, + ...getCommentContextFromAttributes(origComment.attributes), + }, + version: updatedComment?.version ?? origComment.version, + references: origComment?.references ?? [], + }; + }), + }) + ); + } catch (error) { + throw createCaseError({ message: `Failed to push case: ${error}`, error, logger }); + } }; diff --git a/x-pack/plugins/cases/server/client/cases/update.test.ts b/x-pack/plugins/cases/server/client/cases/update.test.ts deleted file mode 100644 index 18b4e8d9d7b66..0000000000000 --- a/x-pack/plugins/cases/server/client/cases/update.test.ts +++ /dev/null @@ -1,768 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { ConnectorTypes, CasesPatchRequest, CaseStatuses } from '../../../common'; -import { isCaseError } from '../../common/error'; -import { - createMockSavedObjectsRepository, - mockCaseNoConnectorId, - mockCases, - mockCaseComments, -} from '../../routes/api/__fixtures__'; -import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; - -describe('update', () => { - beforeEach(async () => { - jest.restoreAllMocks(); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), - })); - }); - - describe('happy path', () => { - test('it closes the case correctly', async () => { - const patchCases = { - cases: [ - { - id: 'mock-id-1', - status: CaseStatuses.closed, - version: 'WzAsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.update(patchCases); - - expect(res).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": "2019-11-25T21:54:48.952Z", - "closed_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "closed", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, - ] - `); - - expect( - casesClient.services.userActionService.postUserActions.mock.calls[0][0].actions - ).toEqual([ - { - attributes: { - action: 'update', - action_at: '2019-11-25T21:54:48.952Z', - action_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - action_field: ['status'], - new_value: CaseStatuses.closed, - old_value: CaseStatuses.open, - }, - references: [ - { - id: 'mock-id-1', - name: 'associated-cases', - type: 'cases', - }, - ], - }, - ]); - }); - - test('it opens the case correctly', async () => { - const patchCases = { - cases: [ - { - id: 'mock-id-1', - status: CaseStatuses.open, - version: 'WzAsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: [ - { - ...mockCases[0], - attributes: { ...mockCases[0].attributes, status: CaseStatuses.closed }, - }, - ...mockCases.slice(1), - ], - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.update(patchCases); - - expect(res).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, - ] - `); - }); - - test('it change the status of case to in-progress correctly', async () => { - const patchCases = { - cases: [ - { - id: 'mock-id-4', - status: CaseStatuses['in-progress'], - version: 'WzUsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.update(patchCases); - - expect(res).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-4", - "settings": Object { - "syncAlerts": true, - }, - "status": "in-progress", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, - ] - `); - }); - - test('it updates a case without a connector.id', async () => { - const patchCases = { - cases: [ - { - id: 'mock-no-connector_id', - status: CaseStatuses.closed, - version: 'WzAsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: [mockCaseNoConnectorId], - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.update(patchCases); - - expect(res).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": "2019-11-25T21:54:48.952Z", - "closed_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-no-connector_id", - "settings": Object { - "syncAlerts": true, - }, - "status": "closed", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, - ] - `); - }); - - test('it updates the connector correctly', async () => { - const patchCases = ({ - cases: [ - { - id: 'mock-id-3', - connector: { - id: '456', - name: 'My connector 2', - type: ConnectorTypes.jira, - fields: { issueType: 'Bug', priority: 'Low', parent: null }, - }, - version: 'WzUsMV0=', - }, - ], - } as unknown) as CasesPatchRequest; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.update(patchCases); - - expect(res).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Bug", - "parent": null, - "priority": "Low", - }, - "id": "456", - "name": "My connector 2", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, - ] - `); - }); - - test('it updates alert status when the status is updated and syncAlerts=true', async () => { - const patchCases = { - cases: [ - { - id: 'mock-id-1', - status: CaseStatuses.closed, - version: 'WzAsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: [ - { - ...mockCaseComments[3], - references: [ - { - type: 'cases', - name: 'associated-cases', - id: 'mock-id-1', - }, - ], - }, - ], - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - casesClient.client.updateAlertsStatus = jest.fn(); - - await casesClient.client.update(patchCases); - - expect(casesClient.client.updateAlertsStatus).toHaveBeenCalledWith({ - alerts: [ - { - id: 'test-id', - index: 'test-index', - status: 'closed', - }, - ], - }); - }); - - test('it does NOT updates alert status when the status is updated and syncAlerts=false', async () => { - const patchCases = { - cases: [ - { - id: 'mock-id-1', - status: CaseStatuses.closed, - version: 'WzAsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: [ - { - ...mockCases[0], - attributes: { ...mockCases[0].attributes, settings: { syncAlerts: false } }, - }, - ], - caseCommentSavedObject: [{ ...mockCaseComments[3] }], - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - - await casesClient.client.update(patchCases); - - expect(casesClient.esClient.bulk).not.toHaveBeenCalled(); - }); - - test('it updates alert status when syncAlerts is turned on', async () => { - const patchCases = { - cases: [ - { - id: 'mock-id-1', - settings: { syncAlerts: true }, - version: 'WzAsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: [ - { - ...mockCases[0], - attributes: { ...mockCases[0].attributes, settings: { syncAlerts: false } }, - }, - ], - caseCommentSavedObject: [{ ...mockCaseComments[3] }], - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - casesClient.client.updateAlertsStatus = jest.fn(); - - await casesClient.client.update(patchCases); - - expect(casesClient.client.updateAlertsStatus).toHaveBeenCalledWith({ - alerts: [{ id: 'test-id', index: 'test-index', status: 'open' }], - }); - }); - - test('it does NOT updates alert status when syncAlerts is turned off', async () => { - const patchCases = { - cases: [ - { - id: 'mock-id-1', - settings: { syncAlerts: false }, - version: 'WzAsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: [{ ...mockCaseComments[3] }], - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - - await casesClient.client.update(patchCases); - - expect(casesClient.esClient.bulk).not.toHaveBeenCalled(); - }); - - test('it updates alert status for multiple cases', async () => { - const patchCases = { - cases: [ - { - id: 'mock-id-1', - settings: { syncAlerts: true }, - version: 'WzAsMV0=', - }, - { - id: 'mock-id-2', - status: CaseStatuses.closed, - version: 'WzQsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: [ - { - ...mockCases[0], - attributes: { ...mockCases[0].attributes, settings: { syncAlerts: false } }, - }, - { - ...mockCases[1], - }, - ], - caseCommentSavedObject: [ - { - ...mockCaseComments[3], - references: [ - { - type: 'cases', - name: 'associated-cases', - id: 'mock-id-1', - }, - ], - }, - { - ...mockCaseComments[4], - references: [ - { - type: 'cases', - name: 'associated-cases', - id: 'mock-id-2', - }, - ], - }, - ], - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - casesClient.client.updateAlertsStatus = jest.fn(); - - await casesClient.client.update(patchCases); - - expect(casesClient.client.updateAlertsStatus).toHaveBeenCalledWith({ - alerts: [ - { id: 'test-id', index: 'test-index', status: 'open' }, - { id: 'test-id-2', index: 'test-index-2', status: 'closed' }, - ], - }); - }); - - test('it does NOT call updateAlertsStatus when there is no comments of type alerts', async () => { - const patchCases = { - cases: [ - { - id: 'mock-id-1', - status: CaseStatuses.closed, - version: 'WzAsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - - await casesClient.client.update(patchCases); - - expect(casesClient.esClient.bulk).not.toHaveBeenCalled(); - }); - }); - - describe('unhappy path', () => { - test('it throws when missing id', async () => { - expect.assertions(3); - const patchCases = { - cases: [ - { - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - version: 'WzUsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return ( - casesClient.client - // @ts-expect-error - .update({ cases: patchCases }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }) - ); - }); - - test('it throws when missing version', async () => { - expect.assertions(3); - const patchCases = { - cases: [ - { - id: 'mock-id-3', - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return ( - casesClient.client - // @ts-expect-error - .update({ cases: patchCases }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }) - ); - }); - - test('it throws when fields are identical', async () => { - expect.assertions(5); - const patchCases = { - cases: [ - { - id: 'mock-id-1', - status: CaseStatuses.open, - version: 'WzAsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return casesClient.client.update(patchCases).catch((e) => { - expect(e).not.toBeNull(); - expect(isCaseError(e)).toBeTruthy(); - const boomErr = e.boomify(); - expect(boomErr.isBoom).toBe(true); - expect(boomErr.output.statusCode).toBe(406); - expect(boomErr.message).toContain('All update fields are identical to current version.'); - }); - }); - - test('it throws when case does not exist', async () => { - expect.assertions(5); - const patchCases = { - cases: [ - { - id: 'not-exists', - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - version: 'WzUsMV0=', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return casesClient.client.update(patchCases).catch((e) => { - expect(e).not.toBeNull(); - expect(isCaseError(e)).toBeTruthy(); - const boomErr = e.boomify(); - expect(boomErr.isBoom).toBe(true); - expect(boomErr.output.statusCode).toBe(404); - expect(boomErr.message).toContain( - 'These cases not-exists do not exist. Please check you have the correct ids.' - ); - }); - }); - - test('it throws when cases conflicts', async () => { - expect.assertions(5); - const patchCases = { - cases: [ - { - id: 'mock-id-1', - version: 'WzAsMV1=', - title: 'Super Bad Security Issue', - }, - ], - }; - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return casesClient.client.update(patchCases).catch((e) => { - expect(e).not.toBeNull(); - expect(isCaseError(e)).toBeTruthy(); - const boomErr = e.boomify(); - expect(boomErr.isBoom).toBe(true); - expect(boomErr.output.statusCode).toBe(409); - expect(boomErr.message).toContain( - 'These cases mock-id-1 has been updated. Please refresh before saving additional updates.' - ); - }); - }); - }); -}); diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index b9926ff6cbb14..db20ba8318447 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -15,12 +15,9 @@ import { SavedObjectsClientContract, SavedObjectsFindResponse, SavedObjectsFindResult, - Logger, } from 'kibana/server'; -import { - flattenCaseSavedObject, - isCommentRequestTypeAlertOrGenAlert, -} from '../../routes/api/utils'; + +import { nodeBuilder } from '../../../../../../src/plugins/data/common'; import { throwErrors, @@ -37,25 +34,28 @@ import { CasesPatchRequest, AssociationType, CommentAttributes, - User, -} from '../../../common'; +} from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; -import { - getCaseToUpdate, - transformCaseConnectorToEsConnector, -} from '../../routes/api/cases/helpers'; +import { getCaseToUpdate } from '../utils'; -import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; +import { CasesService } from '../../services'; import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT, -} from '../../saved_object_types'; -import { CasesClientHandler } from '..'; -import { createAlertUpdateRequest } from '../../common'; -import { UpdateAlertRequest } from '../types'; +} from '../../../common/constants'; +import { + createAlertUpdateRequest, + transformCaseConnectorToEsConnector, + flattenCaseSavedObject, + isCommentRequestTypeAlertOrGenAlert, +} from '../../common'; import { createCaseError } from '../../common/error'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; +import { UpdateAlertRequest } from '../alerts/client'; +import { CasesClientInternal } from '../client_internal'; +import { CasesClientArgs } from '..'; +import { Operations, OwnerEntity } from '../../authorization'; /** * Throws an error if any of the requests attempt to update a collection style cases' status field. @@ -114,6 +114,18 @@ function throwIfUpdateType(requests: ESCasePatchRequest[]) { } } +/** + * Throws an error if any of the requests attempt to update the owner of a case. + */ +function throwIfUpdateOwner(requests: ESCasePatchRequest[]) { + const requestsUpdatingOwner = requests.filter((req) => req.owner !== undefined); + + if (requestsUpdatingOwner.length > 0) { + const ids = requestsUpdatingOwner.map((req) => req.id); + throw Boom.badRequest(`Updating the owner of a case is not allowed ids: [${ids.join(', ')}]`); + } +} + /** * Throws an error if any of the requests attempt to update an individual style cases' type field to a collection * when alerts are attached to the case. @@ -121,20 +133,26 @@ function throwIfUpdateType(requests: ESCasePatchRequest[]) { async function throwIfInvalidUpdateOfTypeWithAlerts({ requests, caseService, - client, + unsecuredSavedObjectsClient, }: { requests: ESCasePatchRequest[]; - caseService: CaseServiceSetup; - client: SavedObjectsClientContract; + caseService: CasesService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; }) { const getAlertsForID = async (caseToUpdate: ESCasePatchRequest) => { const alerts = await caseService.getAllCaseComments({ - client, + unsecuredSavedObjectsClient, id: caseToUpdate.id, options: { fields: [], // there should never be generated alerts attached to an individual case but we'll check anyway - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + filter: nodeBuilder.or([ + nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.alert), + nodeBuilder.is( + `${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, + CommentType.generatedAlert + ), + ]), page: 1, perPage: 1, }, @@ -177,21 +195,24 @@ function getID( async function getAlertComments({ casesToSync, caseService, - client, + unsecuredSavedObjectsClient, }: { casesToSync: ESCasePatchRequest[]; - caseService: CaseServiceSetup; - client: SavedObjectsClientContract; + caseService: CasesService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; }): Promise> { const idsOfCasesToSync = casesToSync.map((casePatchReq) => casePatchReq.id); // getAllCaseComments will by default get all the comments, unless page or perPage fields are set return caseService.getAllCaseComments({ - client, + unsecuredSavedObjectsClient, id: idsOfCasesToSync, includeSubCaseComments: true, options: { - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + filter: nodeBuilder.or([ + nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.alert), + nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.generatedAlert), + ]), }, }); } @@ -203,11 +224,11 @@ async function getAlertComments({ async function getSubCasesToStatus({ totalAlerts, caseService, - client, + unsecuredSavedObjectsClient, }: { totalAlerts: SavedObjectsFindResponse; - caseService: CaseServiceSetup; - client: SavedObjectsClientContract; + caseService: CasesService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; }): Promise> { const subCasesToRetrieve = totalAlerts.saved_objects.reduce((acc, alertComment) => { if ( @@ -224,7 +245,7 @@ async function getSubCasesToStatus({ const subCases = await caseService.getSubCases({ ids: Array.from(subCasesToRetrieve.values()), - client, + unsecuredSavedObjectsClient, }); return subCases.saved_objects.reduce((acc, subCase) => { @@ -270,15 +291,15 @@ async function updateAlerts({ casesWithStatusChangedAndSynced, casesMap, caseService, - client, - casesClient, + unsecuredSavedObjectsClient, + casesClientInternal, }: { casesWithSyncSettingChangedToOn: ESCasePatchRequest[]; casesWithStatusChangedAndSynced: ESCasePatchRequest[]; casesMap: Map>; - caseService: CaseServiceSetup; - client: SavedObjectsClientContract; - casesClient: CasesClientHandler; + caseService: CasesService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + casesClientInternal: CasesClientInternal; }) { /** * It's possible that a case ID can appear multiple times in each array. I'm intentionally placing the status changes @@ -302,11 +323,15 @@ async function updateAlerts({ const totalAlerts = await getAlertComments({ casesToSync, caseService, - client, + unsecuredSavedObjectsClient, }); // get a map of sub case id to the sub case status - const subCasesToStatus = await getSubCasesToStatus({ totalAlerts, client, caseService }); + const subCasesToStatus = await getSubCasesToStatus({ + totalAlerts, + unsecuredSavedObjectsClient, + caseService, + }); // create an array of requests that indicate the id, index, and status to update an alert const alertsToUpdate = totalAlerts.saved_objects.reduce( @@ -326,28 +351,61 @@ async function updateAlerts({ [] ); - await casesClient.updateAlertsStatus({ alerts: alertsToUpdate }); + await casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate }); } -interface UpdateArgs { - savedObjectsClient: SavedObjectsClientContract; - caseService: CaseServiceSetup; - userActionService: CaseUserActionServiceSetup; - user: User; - casesClient: CasesClientHandler; - cases: CasesPatchRequest; - logger: Logger; +function partitionPatchRequest( + casesMap: Map>, + patchReqCases: CasePatchRequest[] +): { + nonExistingCases: CasePatchRequest[]; + conflictedCases: CasePatchRequest[]; + // This will be a deduped array of case IDs with their corresponding owner + casesToAuthorize: OwnerEntity[]; +} { + const nonExistingCases: CasePatchRequest[] = []; + const conflictedCases: CasePatchRequest[] = []; + const casesToAuthorize: Map = new Map(); + + for (const reqCase of patchReqCases) { + const foundCase = casesMap.get(reqCase.id); + + if (!foundCase || foundCase.error) { + nonExistingCases.push(reqCase); + } else if (foundCase.version !== reqCase.version) { + conflictedCases.push(reqCase); + // let's try to authorize the conflicted case even though we'll fail after afterwards just in case + casesToAuthorize.set(foundCase.id, { id: foundCase.id, owner: foundCase.attributes.owner }); + } else { + casesToAuthorize.set(foundCase.id, { id: foundCase.id, owner: foundCase.attributes.owner }); + } + } + + return { + nonExistingCases, + conflictedCases, + casesToAuthorize: Array.from(casesToAuthorize.values()), + }; } -export const update = async ({ - savedObjectsClient, - caseService, - userActionService, - user, - casesClient, - cases, - logger, -}: UpdateArgs): Promise => { +/** + * Updates the specified cases with new values + * + * @ignore + */ +export const update = async ( + cases: CasesPatchRequest, + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise => { + const { + unsecuredSavedObjectsClient, + caseService, + userActionService, + user, + logger, + authorization, + } = clientArgs; const query = pipe( excess(CasesPatchRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) @@ -355,19 +413,23 @@ export const update = async ({ try { const myCases = await caseService.getCases({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, caseIds: query.cases.map((q) => q.id), }); - let nonExistingCases: CasePatchRequest[] = []; - const conflictedCases = query.cases.filter((q) => { - const myCase = myCases.saved_objects.find((c) => c.id === q.id); + const casesMap = myCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); - if (myCase && myCase.error) { - nonExistingCases = [...nonExistingCases, q]; - return false; - } - return myCase == null || myCase?.version !== q.version; + const { nonExistingCases, conflictedCases, casesToAuthorize } = partitionPatchRequest( + casesMap, + query.cases + ); + + await authorization.ensureAuthorized({ + entities: casesToAuthorize, + operation: Operations.updateCase, }); if (nonExistingCases.length > 0) { @@ -408,30 +470,27 @@ export const update = async ({ throw Boom.notAcceptable('All update fields are identical to current version.'); } - const casesMap = myCases.saved_objects.reduce((acc, so) => { - acc.set(so.id, so); - return acc; - }, new Map>()); - if (!ENABLE_CASE_CONNECTOR) { throwIfUpdateType(updateFilterCases); } + throwIfUpdateOwner(updateFilterCases); throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); await throwIfInvalidUpdateOfTypeWithAlerts({ requests: updateFilterCases, caseService, - client: savedObjectsClient, + unsecuredSavedObjectsClient, }); // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = user; const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, cases: updateFilterCases.map((thisCase) => { - const { id: caseId, version, ...updateCaseAttributes } = thisCase; + // intentionally removing owner from the case so that we don't accidentally allow it to be updated + const { id: caseId, version, owner, ...updateCaseAttributes } = thisCase; let closedInfo = {}; if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { closedInfo = { @@ -490,8 +549,8 @@ export const update = async ({ casesWithStatusChangedAndSynced, casesWithSyncSettingChangedToOn, caseService, - client: savedObjectsClient, - casesClient, + unsecuredSavedObjectsClient, + casesClientInternal, casesMap, }); @@ -512,8 +571,8 @@ export const update = async ({ }); }); - await userActionService.postUserActions({ - client: savedObjectsClient, + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, actions: buildCaseUserActions({ originalCases: myCases.saved_objects, updatedCases: updatedCases.saved_objects, diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index c24812048376e..9f18fa4931e62 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -6,7 +6,6 @@ */ import { actionsClientMock } from '../../../../actions/server/actions_client.mock'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; import { mockCases } from '../../routes/api/__fixtures__'; import { BasicParams, ExternalServiceParams, Incident } from './types'; @@ -29,6 +28,8 @@ import { transformers, transformFields, } from './utils'; +import { flattenCaseSavedObject } from '../../common'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; const formatComment = { commentId: commentObj.id, @@ -701,6 +702,7 @@ describe('utils', () => { action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53', case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', comment_id: null, + owner: SECURITY_SOLUTION_OWNER, }, ]); diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index 9bfad7ddcec3c..ebcc5a07b4edd 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -38,7 +38,7 @@ import { TransformerArgs, TransformFieldsArgs, } from './types'; -import { getAlertIds } from '../../routes/api/utils'; +import { getAlertIds } from '../utils'; interface CreateIncidentArgs { actionsClient: ActionsClient; @@ -329,11 +329,13 @@ export const isCommentAlertType = ( export const getCommentContextFromAttributes = ( attributes: CommentAttributes ): CommentRequestUserType | CommentRequestAlertType => { + const owner = attributes.owner; switch (attributes.type) { case CommentType.user: return { type: CommentType.user, comment: attributes.comment, + owner, }; case CommentType.generatedAlert: case CommentType.alert: @@ -342,11 +344,13 @@ export const getCommentContextFromAttributes = ( alertId: attributes.alertId, index: attributes.index, rule: attributes.rule, + owner, }; default: return { type: CommentType.user, comment: '', + owner, }; } }; diff --git a/x-pack/plugins/cases/server/client/client.ts b/x-pack/plugins/cases/server/client/client.ts index 3bd25b6b61bc5..4b21b401f5b7b 100644 --- a/x-pack/plugins/cases/server/client/client.ts +++ b/x-pack/plugins/cases/server/client/client.ts @@ -5,246 +5,94 @@ * 2.0. */ -import { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'src/core/server'; -import { - CasesClientFactoryArguments, - CasesClient, - ConfigureFields, - MappingsClient, - CasesClientUpdateAlertsStatus, - CasesClientAddComment, - CasesClientGet, - CasesClientGetUserActions, - CasesClientGetAlerts, - CasesClientPush, -} from './types'; -import { create } from './cases/create'; -import { update } from './cases/update'; -import { addComment } from './comments/add'; -import { getFields } from './configure/get_fields'; -import { getMappings } from './configure/get_mappings'; -import { updateAlertsStatus } from './alerts/update_status'; -import { - CaseConfigureServiceSetup, - CaseServiceSetup, - ConnectorMappingsServiceSetup, - CaseUserActionServiceSetup, - AlertServiceContract, -} from '../services'; -import { CasesPatchRequest, CasePostRequest, User } from '../../common'; -import { get } from './cases/get'; -import { get as getUserActions } from './user_actions/get'; -import { get as getAlerts } from './alerts/get'; -import { push } from './cases/push'; -import { createCaseError } from '../common/error'; +import { CasesClientArgs } from './types'; +import { CasesSubClient, createCasesSubClient } from './cases/client'; +import { AttachmentsSubClient, createAttachmentsSubClient } from './attachments/client'; +import { UserActionsSubClient, createUserActionsSubClient } from './user_actions/client'; +import { CasesClientInternal, createCasesClientInternal } from './client_internal'; +import { createSubCasesClient, SubCasesClient } from './sub_cases/client'; +import { ENABLE_CASE_CONNECTOR } from '../../common/constants'; +import { ConfigureSubClient, createConfigurationSubClient } from './configure/client'; +import { createStatsSubClient, StatsSubClient } from './stats/client'; /** - * This class is a pass through for common case functionality (like creating, get a case). + * Client wrapper that contains accessor methods for individual entities within the cases system. */ -export class CasesClientHandler implements CasesClient { - private readonly _scopedClusterClient: ElasticsearchClient; - private readonly _caseConfigureService: CaseConfigureServiceSetup; - private readonly _caseService: CaseServiceSetup; - private readonly _connectorMappingsService: ConnectorMappingsServiceSetup; - private readonly user: User; - private readonly _savedObjectsClient: SavedObjectsClientContract; - private readonly _userActionService: CaseUserActionServiceSetup; - private readonly _alertsService: AlertServiceContract; - private readonly logger: Logger; +export class CasesClient { + private readonly _casesClientInternal: CasesClientInternal; + private readonly _cases: CasesSubClient; + private readonly _attachments: AttachmentsSubClient; + private readonly _userActions: UserActionsSubClient; + private readonly _subCases: SubCasesClient; + private readonly _configure: ConfigureSubClient; + private readonly _stats: StatsSubClient; - constructor(clientArgs: CasesClientFactoryArguments) { - this._scopedClusterClient = clientArgs.scopedClusterClient; - this._caseConfigureService = clientArgs.caseConfigureService; - this._caseService = clientArgs.caseService; - this._connectorMappingsService = clientArgs.connectorMappingsService; - this.user = clientArgs.user; - this._savedObjectsClient = clientArgs.savedObjectsClient; - this._userActionService = clientArgs.userActionService; - this._alertsService = clientArgs.alertsService; - this.logger = clientArgs.logger; + constructor(args: CasesClientArgs) { + this._casesClientInternal = createCasesClientInternal(args); + this._cases = createCasesSubClient(args, this, this._casesClientInternal); + this._attachments = createAttachmentsSubClient(args, this._casesClientInternal); + this._userActions = createUserActionsSubClient(args); + this._subCases = createSubCasesClient(args, this._casesClientInternal); + this._configure = createConfigurationSubClient(args, this._casesClientInternal); + this._stats = createStatsSubClient(args); } - public async create(caseInfo: CasePostRequest) { - try { - return create({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - caseConfigureService: this._caseConfigureService, - userActionService: this._userActionService, - user: this.user, - theCase: caseInfo, - logger: this.logger, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to create a new case using client: ${error}`, - error, - logger: this.logger, - }); - } - } - - public async update(cases: CasesPatchRequest) { - try { - return update({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - user: this.user, - cases, - casesClient: this, - logger: this.logger, - }); - } catch (error) { - const caseIDVersions = cases.cases.map((caseInfo) => ({ - id: caseInfo.id, - version: caseInfo.version, - })); - throw createCaseError({ - message: `Failed to update cases using client: ${JSON.stringify(caseIDVersions)}: ${error}`, - error, - logger: this.logger, - }); - } - } - - public async addComment({ caseId, comment }: CasesClientAddComment) { - try { - return addComment({ - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - casesClient: this, - caseId, - comment, - user: this.user, - logger: this.logger, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to add comment using client case id: ${caseId}: ${error}`, - error, - logger: this.logger, - }); - } - } - - public async getFields(fields: ConfigureFields) { - try { - return getFields(fields); - } catch (error) { - throw createCaseError({ - message: `Failed to retrieve fields using client: ${error}`, - error, - logger: this.logger, - }); - } + /** + * Retrieves an interface for interacting with cases entities. + */ + public get cases() { + return this._cases; } - public async getMappings(args: MappingsClient) { - try { - return getMappings({ - ...args, - savedObjectsClient: this._savedObjectsClient, - connectorMappingsService: this._connectorMappingsService, - casesClient: this, - logger: this.logger, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to get mappings using client: ${error}`, - error, - logger: this.logger, - }); - } + /** + * Retrieves an interface for interacting with attachments (comments) entities. + */ + public get attachments() { + return this._attachments; } - public async updateAlertsStatus(args: CasesClientUpdateAlertsStatus) { - try { - return updateAlertsStatus({ - ...args, - alertsService: this._alertsService, - scopedClusterClient: this._scopedClusterClient, - logger: this.logger, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to update alerts status using client alerts: ${JSON.stringify( - args.alerts - )}: ${error}`, - error, - logger: this.logger, - }); - } - } - - public async get(args: CasesClientGet) { - try { - return get({ - ...args, - caseService: this._caseService, - savedObjectsClient: this._savedObjectsClient, - logger: this.logger, - }); - } catch (error) { - this.logger.error(`Failed to get case using client id: ${args.id}: ${error}`); - throw error; - } + /** + * Retrieves an interface for interacting with the user actions associated with the plugin entities. + */ + public get userActions() { + return this._userActions; } - public async getUserActions(args: CasesClientGetUserActions) { - try { - return getUserActions({ - ...args, - savedObjectsClient: this._savedObjectsClient, - userActionService: this._userActionService, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to get user actions using client id: ${args.caseId}: ${error}`, - error, - logger: this.logger, - }); + /** + * Retrieves an interface for interacting with the case as a connector entities. + * + * Currently this functionality is disabled and will throw an error if this function is called. + */ + public get subCases() { + if (!ENABLE_CASE_CONNECTOR) { + throw new Error('The case connector feature is disabled'); } + return this._subCases; } - public async getAlerts(args: CasesClientGetAlerts) { - try { - return getAlerts({ - ...args, - alertsService: this._alertsService, - scopedClusterClient: this._scopedClusterClient, - logger: this.logger, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to get alerts using client requested alerts: ${JSON.stringify( - args.alertsInfo - )}: ${error}`, - error, - logger: this.logger, - }); - } + /** + * Retrieves an interface for interacting with the configuration of external connectors for the plugin entities. + */ + public get configure() { + return this._configure; } - public async push(args: CasesClientPush) { - try { - return push({ - ...args, - savedObjectsClient: this._savedObjectsClient, - caseService: this._caseService, - userActionService: this._userActionService, - user: this.user, - casesClient: this, - caseConfigureService: this._caseConfigureService, - logger: this.logger, - }); - } catch (error) { - throw createCaseError({ - message: `Failed to push case using client id: ${args.caseId}: ${error}`, - error, - logger: this.logger, - }); - } + /** + * Retrieves an interface for retrieving statistics related to the cases entities. + */ + public get stats() { + return this._stats; } } + +/** + * Creates a {@link CasesClient} for interacting with the cases entities + * + * @param args arguments for initializing the cases client + * @returns a {@link CasesClient} + * + * @ignore + */ +export const createCasesClient = (args: CasesClientArgs): CasesClient => { + return new CasesClient(args); +}; diff --git a/x-pack/plugins/cases/server/client/client_internal.ts b/x-pack/plugins/cases/server/client/client_internal.ts new file mode 100644 index 0000000000000..3623498223da7 --- /dev/null +++ b/x-pack/plugins/cases/server/client/client_internal.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 { CasesClientArgs } from './types'; +import { AlertSubClient, createAlertsSubClient } from './alerts/client'; +import { + InternalConfigureSubClient, + createInternalConfigurationSubClient, +} from './configure/client'; + +export class CasesClientInternal { + private readonly _alerts: AlertSubClient; + private readonly _configuration: InternalConfigureSubClient; + + constructor(args: CasesClientArgs) { + this._alerts = createAlertsSubClient(args); + this._configuration = createInternalConfigurationSubClient(args, this); + } + + public get alerts() { + return this._alerts; + } + + public get configuration() { + return this._configuration; + } +} + +export const createCasesClientInternal = (args: CasesClientArgs): CasesClientInternal => { + return new CasesClientInternal(args); +}; diff --git a/x-pack/plugins/cases/server/client/comments/add.test.ts b/x-pack/plugins/cases/server/client/comments/add.test.ts deleted file mode 100644 index bd04e0ea6ef14..0000000000000 --- a/x-pack/plugins/cases/server/client/comments/add.test.ts +++ /dev/null @@ -1,593 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { omit } from 'lodash/fp'; -import { CommentType } from '../../../common'; -import { isCaseError } from '../../common/error'; -import { - createMockSavedObjectsRepository, - mockCaseComments, - mockCases, -} from '../../routes/api/__fixtures__'; -import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; - -type AlertComment = CommentType.alert | CommentType.generatedAlert; - -describe('addComment', () => { - beforeEach(async () => { - jest.restoreAllMocks(); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2020-10-23T21:54:48.952Z'), - })); - }); - - describe('happy path', () => { - test('it adds a comment correctly', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.addComment({ - caseId: 'mock-id-1', - comment: { - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - }, - }); - - expect(res.id).toEqual('mock-id-1'); - expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` - Object { - "associationType": "case", - "comment": "Wow, good luck catching that bad meanie!", - "created_at": "2020-10-23T21:54:48.952Z", - "created_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "id": "mock-comment", - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", - } - `); - }); - - test('it adds a comment of type alert correctly', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.addComment({ - caseId: 'mock-id-1', - comment: { - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - rule: { - id: 'test-rule1', - name: 'test-rule', - }, - }, - }); - - expect(res.id).toEqual('mock-id-1'); - expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` - Object { - "alertId": "test-id", - "associationType": "case", - "created_at": "2020-10-23T21:54:48.952Z", - "created_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "id": "mock-comment", - "index": "test-index", - "pushed_at": null, - "pushed_by": null, - "rule": Object { - "id": "test-rule1", - "name": "test-rule", - }, - "type": "alert", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", - } - `); - }); - - test('it updates the case correctly after adding a comment', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.addComment({ - caseId: 'mock-id-1', - comment: { - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - }, - }); - - expect(res.updated_at).toEqual('2020-10-23T21:54:48.952Z'); - expect(res.updated_by).toEqual({ - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }); - }); - - test('it creates a user action', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - await casesClient.client.addComment({ - caseId: 'mock-id-1', - comment: { - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - }, - }); - - expect( - casesClient.services.userActionService.postUserActions.mock.calls[0][0].actions - ).toEqual([ - { - attributes: { - action: 'create', - action_at: '2020-10-23T21:54:48.952Z', - action_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - action_field: ['comment'], - new_value: '{"comment":"Wow, good luck catching that bad meanie!","type":"user"}', - old_value: null, - }, - references: [ - { - id: 'mock-id-1', - name: 'associated-cases', - type: 'cases', - }, - { - id: 'mock-comment', - name: 'associated-cases-comments', - type: 'cases-comments', - }, - ], - }, - ]); - }); - - test('it allow user to create comments without authentications', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ - savedObjectsClient, - badAuth: true, - }); - const res = await casesClient.client.addComment({ - caseId: 'mock-id-1', - comment: { - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - }, - }); - - expect(res.id).toEqual('mock-id-1'); - expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` - Object { - "associationType": "case", - "comment": "Wow, good luck catching that bad meanie!", - "created_at": "2020-10-23T21:54:48.952Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "id": "mock-comment", - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", - } - `); - }); - - test('it update the status of the alert if the case is synced with alerts', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ - savedObjectsClient, - badAuth: true, - }); - - casesClient.client.updateAlertsStatus = jest.fn(); - - await casesClient.client.addComment({ - caseId: 'mock-id-1', - comment: { - type: CommentType.alert, - alertId: 'test-alert', - index: 'test-index', - rule: { - id: 'test-rule1', - name: 'test-rule', - }, - }, - }); - - expect(casesClient.client.updateAlertsStatus).toHaveBeenCalledWith({ - alerts: [{ id: 'test-alert', index: 'test-index', status: 'open' }], - }); - }); - - test('it should NOT update the status of the alert if the case is NOT synced with alerts', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: [ - { - ...mockCases[0], - attributes: { ...mockCases[0].attributes, settings: { syncAlerts: false } }, - }, - ], - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ - savedObjectsClient, - badAuth: true, - }); - - casesClient.client.updateAlertsStatus = jest.fn(); - - await casesClient.client.addComment({ - caseId: 'mock-id-1', - comment: { - type: CommentType.alert, - alertId: 'test-alert', - index: 'test-index', - rule: { - id: 'test-rule1', - name: 'test-rule', - }, - }, - }); - - expect(casesClient.client.updateAlertsStatus).not.toHaveBeenCalled(); - }); - }); - - describe('unhappy path', () => { - test('it throws when missing type', async () => { - expect.assertions(3); - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return casesClient.client - .addComment({ - caseId: 'mock-id-1', - // @ts-expect-error - comment: {}, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); - }); - - test('it throws when missing attributes: type user', async () => { - expect.assertions(3); - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const allRequestAttributes = { - type: CommentType.user, - comment: 'a comment', - }; - - ['comment'].forEach((attribute) => { - const requestAttributes = omit(attribute, allRequestAttributes); - return casesClient.client - .addComment({ - caseId: 'mock-id-1', - // @ts-expect-error - comment: { - ...requestAttributes, - }, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); - }); - }); - - test('it throws when excess attributes are provided: type user', async () => { - expect.assertions(6); - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - - ['alertId', 'index'].forEach((attribute) => { - return casesClient.client - .addComment({ - caseId: 'mock-id-1', - comment: { - [attribute]: attribute, - comment: 'a comment', - type: CommentType.user, - }, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); - }); - }); - - test('it throws when missing attributes: type alert', async () => { - expect.assertions(6); - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const allRequestAttributes = { - type: CommentType.alert, - index: 'test-index', - alertId: 'test-id', - }; - - ['alertId', 'index'].forEach((attribute) => { - const requestAttributes = omit(attribute, allRequestAttributes); - return casesClient.client - .addComment({ - caseId: 'mock-id-1', - // @ts-expect-error - comment: { - ...requestAttributes, - }, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); - }); - }); - - test('it throws when excess attributes are provided: type alert', async () => { - expect.assertions(3); - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - - ['comment'].forEach((attribute) => { - return casesClient.client - .addComment({ - caseId: 'mock-id-1', - comment: { - [attribute]: attribute, - type: CommentType.alert, - index: 'test-index', - alertId: 'test-id', - rule: { - id: 'test-rule1', - name: 'test-rule', - }, - }, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(400); - }); - }); - }); - - test('it throws when the case does not exists', async () => { - expect.assertions(4); - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return casesClient.client - .addComment({ - caseId: 'not-exists', - comment: { - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - }, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(isCaseError(e)).toBeTruthy(); - const boomErr = e.boomify(); - expect(boomErr.isBoom).toBe(true); - expect(boomErr.output.statusCode).toBe(404); - }); - }); - - test('it throws when postNewCase throws', async () => { - expect.assertions(4); - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return casesClient.client - .addComment({ - caseId: 'mock-id-1', - comment: { - comment: 'Throw an error', - type: CommentType.user, - }, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(isCaseError(e)).toBeTruthy(); - const boomErr = e.boomify(); - expect(boomErr.isBoom).toBe(true); - expect(boomErr.output.statusCode).toBe(400); - }); - }); - - test('it throws when the case is closed and the comment is of type alert', async () => { - expect.assertions(4); - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - return casesClient.client - .addComment({ - caseId: 'mock-id-4', - comment: { - type: CommentType.alert, - alertId: 'test-alert', - index: 'test-index', - rule: { - id: 'test-rule1', - name: 'test-rule', - }, - }, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(isCaseError(e)).toBeTruthy(); - const boomErr = e.boomify(); - expect(boomErr.isBoom).toBe(true); - expect(boomErr.output.statusCode).toBe(400); - }); - }); - - describe('alert format', () => { - it.each([ - ['1', ['index1', 'index2'], CommentType.alert], - [['1', '2'], 'index', CommentType.alert], - ['1', ['index1', 'index2'], CommentType.generatedAlert], - [['1', '2'], 'index', CommentType.generatedAlert], - ])( - 'throws an error with an alert comment with contents id: %p indices: %p type: %s', - async (alertId, index, type) => { - expect.assertions(1); - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ - savedObjectsClient, - }); - await expect( - casesClient.client.addComment({ - caseId: 'mock-id-4', - comment: { - // casting because type must be either alert or generatedAlert but type is CommentType - type: type as AlertComment, - alertId, - index, - rule: { - id: 'test-rule1', - name: 'test-rule', - }, - }, - }) - ).rejects.toThrow(); - } - ); - - it.each([ - ['1', ['index1'], CommentType.alert], - [['1', '2'], ['index', 'other-index'], CommentType.alert], - ])( - 'does not throw an error with an alert comment with contents id: %p indices: %p type: %s', - async (alertId, index, type) => { - expect.assertions(1); - - const savedObjectsClient = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - - const casesClient = await createCasesClientWithMockSavedObjectsClient({ - savedObjectsClient, - }); - await expect( - casesClient.client.addComment({ - caseId: 'mock-id-1', - comment: { - // casting because type must be either alert or generatedAlert but type is CommentType - type: type as AlertComment, - alertId, - index, - rule: { - id: 'test-rule1', - name: 'test-rule', - }, - }, - }) - ).resolves.not.toBeUndefined(); - } - ); - }); - }); -}); diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts new file mode 100644 index 0000000000000..14348e03f99cc --- /dev/null +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -0,0 +1,461 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { SavedObjectsFindResponse, SavedObjectsUtils } from '../../../../../../src/core/server'; +import { SUPPORTED_CONNECTORS } from '../../../common/constants'; +import { + CaseConfigureResponseRt, + CasesConfigurePatch, + CasesConfigureRequest, + CasesConfigureResponse, + ConnectorMappingsAttributes, + excess, + GetConfigureFindRequest, + GetConfigureFindRequestRt, + GetFieldsResponse, + throwErrors, + CasesConfigurationsResponse, + CaseConfigurationsResponseRt, + CasesConfigurePatchRt, + ConnectorMappings, +} from '../../../common/api'; +import { createCaseError } from '../../common/error'; +import { + transformCaseConnectorToEsConnector, + transformESConnectorToCaseConnector, +} from '../../common'; +import { CasesClientInternal } from '../client_internal'; +import { CasesClientArgs } from '../types'; +import { getFields } from './get_fields'; +import { getMappings } from './get_mappings'; + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { FindActionResult } from '../../../../actions/server/types'; +import { ActionType } from '../../../../actions/common'; +import { Operations } from '../../authorization'; +import { combineAuthorizedAndOwnerFilter } from '../utils'; +import { + ConfigurationGetFields, + MappingsArgs, + CreateMappingsArgs, + UpdateMappingsArgs, +} from './types'; +import { createMappings } from './create_mappings'; +import { updateMappings } from './update_mappings'; +import { + ICasesConfigurePatch, + ICasesConfigureRequest, + ICasesConfigureResponse, +} from '../typedoc_interfaces'; + +/** + * Defines the internal helper functions. + * + * @ignore + */ +export interface InternalConfigureSubClient { + getFields(params: ConfigurationGetFields): Promise; + getMappings( + params: MappingsArgs + ): Promise['saved_objects']>; + createMappings(params: CreateMappingsArgs): Promise; + updateMappings(params: UpdateMappingsArgs): Promise; +} + +/** + * This is the public API for interacting with the connector configuration for cases. + */ +export interface ConfigureSubClient { + /** + * Retrieves the external connector configuration for a particular case owner. + */ + get(params: GetConfigureFindRequest): Promise; + /** + * Retrieves the valid external connectors supported by the cases plugin. + */ + getConnectors(): Promise; + /** + * Updates a particular configuration with new values. + * + * @param configurationId the ID of the configuration to update + * @param configurations the new configuration parameters + */ + update( + configurationId: string, + configurations: ICasesConfigurePatch + ): Promise; + /** + * Creates a configuration if one does not already exist. If one exists it is deleted and a new one is created. + */ + create(configuration: ICasesConfigureRequest): Promise; +} + +/** + * These functions should not be exposed on the plugin contract. They are for internal use to support the CRUD of + * configurations. + * + * @ignore + */ +export const createInternalConfigurationSubClient = ( + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): InternalConfigureSubClient => { + const configureSubClient: InternalConfigureSubClient = { + getFields: (params: ConfigurationGetFields) => getFields(params, clientArgs), + getMappings: (params: MappingsArgs) => getMappings(params, clientArgs), + createMappings: (params: CreateMappingsArgs) => + createMappings(params, clientArgs, casesClientInternal), + updateMappings: (params: UpdateMappingsArgs) => + updateMappings(params, clientArgs, casesClientInternal), + }; + + return Object.freeze(configureSubClient); +}; + +/** + * Creates an API object for interacting with the configuration entities + * + * @ignore + */ +export const createConfigurationSubClient = ( + clientArgs: CasesClientArgs, + casesInternalClient: CasesClientInternal +): ConfigureSubClient => { + return Object.freeze({ + get: (params: GetConfigureFindRequest) => get(params, clientArgs, casesInternalClient), + getConnectors: () => getConnectors(clientArgs), + update: (configurationId: string, configuration: CasesConfigurePatch) => + update(configurationId, configuration, clientArgs, casesInternalClient), + create: (configuration: CasesConfigureRequest) => + create(configuration, clientArgs, casesInternalClient), + }); +}; + +async function get( + params: GetConfigureFindRequest, + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise { + const { unsecuredSavedObjectsClient, caseConfigureService, logger, authorization } = clientArgs; + try { + const queryParams = pipe( + excess(GetConfigureFindRequestRt).decode(params), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { + filter: authorizationFilter, + ensureSavedObjectsAreAuthorized, + } = await authorization.getAuthorizationFilter(Operations.findConfigurations); + + const filter = combineAuthorizedAndOwnerFilter( + queryParams.owner, + authorizationFilter, + Operations.findConfigurations.savedObjectType + ); + + let error: string | null = null; + const myCaseConfigure = await caseConfigureService.find({ + unsecuredSavedObjectsClient, + options: { filter }, + }); + + ensureSavedObjectsAreAuthorized( + myCaseConfigure.saved_objects.map((configuration) => ({ + id: configuration.id, + owner: configuration.attributes.owner, + })) + ); + + const configurations = await Promise.all( + myCaseConfigure.saved_objects.map(async (configuration) => { + const { connector, ...caseConfigureWithoutConnector } = configuration?.attributes ?? { + connector: null, + }; + + let mappings: SavedObjectsFindResponse['saved_objects'] = []; + + if (connector != null) { + try { + mappings = await casesClientInternal.configuration.getMappings({ + connectorId: connector.id, + connectorType: connector.type, + }); + } catch (e) { + error = e.isBoom + ? e.output.payload.message + : `Failed to retrieve mapping for ${connector.name}`; + } + } + + return { + ...caseConfigureWithoutConnector, + connector: transformESConnectorToCaseConnector(connector), + mappings: mappings.length > 0 ? mappings[0].attributes.mappings : [], + version: configuration.version ?? '', + error, + id: configuration.id, + }; + }) + ); + + return CaseConfigurationsResponseRt.encode(configurations); + } catch (error) { + throw createCaseError({ message: `Failed to get case configure: ${error}`, error, logger }); + } +} + +async function getConnectors({ + actionsClient, + logger, +}: CasesClientArgs): Promise { + const isConnectorSupported = ( + action: FindActionResult, + actionTypes: Record + ): boolean => + SUPPORTED_CONNECTORS.includes(action.actionTypeId) && + actionTypes[action.actionTypeId]?.enabledInLicense; + + try { + const actionTypes = (await actionsClient.listTypes()).reduce( + (types, type) => ({ ...types, [type.id]: type }), + {} + ); + + return (await actionsClient.getAll()).filter((action) => + isConnectorSupported(action, actionTypes) + ); + } catch (error) { + throw createCaseError({ message: `Failed to get connectors: ${error}`, error, logger }); + } +} + +async function update( + configurationId: string, + req: CasesConfigurePatch, + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise { + const { + caseConfigureService, + logger, + unsecuredSavedObjectsClient, + user, + authorization, + } = clientArgs; + + try { + const request = pipe( + CasesConfigurePatchRt.decode(req), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { version, ...queryWithoutVersion } = request; + + /** + * Excess function does not supports union or intersection types. + * For that reason we need to check manually for excess properties + * in the partial attributes. + * + * The owner attribute should not be allowed. + */ + pipe( + excess(CasesConfigurePatchRt.types[0]).decode(queryWithoutVersion), + fold(throwErrors(Boom.badRequest), identity) + ); + + const configuration = await caseConfigureService.get({ + unsecuredSavedObjectsClient, + configurationId, + }); + + await authorization.ensureAuthorized({ + operation: Operations.updateConfiguration, + entities: [{ owner: configuration.attributes.owner, id: configuration.id }], + }); + + if (version !== configuration.version) { + throw Boom.conflict( + 'This configuration has been updated. Please refresh before saving additional updates.' + ); + } + + let error = null; + const updateDate = new Date().toISOString(); + let mappings: ConnectorMappingsAttributes[] = []; + const { connector, ...queryWithoutVersionAndConnector } = queryWithoutVersion; + + try { + const resMappings = await casesClientInternal.configuration.getMappings({ + connectorId: connector != null ? connector.id : configuration.attributes.connector.id, + connectorType: connector != null ? connector.type : configuration.attributes.connector.type, + }); + mappings = resMappings.length > 0 ? resMappings[0].attributes.mappings : []; + + if (connector != null) { + if (resMappings.length !== 0) { + mappings = await casesClientInternal.configuration.updateMappings({ + connectorId: connector.id, + connectorType: connector.type, + mappingId: resMappings[0].id, + }); + } else { + mappings = await casesClientInternal.configuration.createMappings({ + connectorId: connector.id, + connectorType: connector.type, + owner: configuration.attributes.owner, + }); + } + } + } catch (e) { + error = e.isBoom + ? e.output.payload.message + : `Error connecting to ${ + connector != null ? connector.name : configuration.attributes.connector.name + } instance`; + } + + const patch = await caseConfigureService.patch({ + unsecuredSavedObjectsClient, + configurationId: configuration.id, + updatedAttributes: { + ...queryWithoutVersionAndConnector, + ...(connector != null ? { connector: transformCaseConnectorToEsConnector(connector) } : {}), + updated_at: updateDate, + updated_by: user, + }, + }); + + return CaseConfigureResponseRt.encode({ + ...configuration.attributes, + ...patch.attributes, + connector: transformESConnectorToCaseConnector( + patch.attributes.connector ?? configuration.attributes.connector + ), + mappings, + version: patch.version ?? '', + error, + id: patch.id, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get patch configure in route: ${error}`, + error, + logger, + }); + } +} + +async function create( + configuration: CasesConfigureRequest, + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise { + const { + unsecuredSavedObjectsClient, + caseConfigureService, + logger, + user, + authorization, + } = clientArgs; + try { + let error = null; + + const { + filter: authorizationFilter, + ensureSavedObjectsAreAuthorized, + } = await authorization.getAuthorizationFilter( + /** + * The operation is createConfiguration because the procedure is part of + * the create route. The user should have all + * permissions to delete the results. + */ + Operations.createConfiguration + ); + + const filter = combineAuthorizedAndOwnerFilter( + configuration.owner, + authorizationFilter, + Operations.createConfiguration.savedObjectType + ); + + const myCaseConfigure = await caseConfigureService.find({ + unsecuredSavedObjectsClient, + options: { filter }, + }); + + ensureSavedObjectsAreAuthorized( + myCaseConfigure.saved_objects.map((conf) => ({ + id: conf.id, + owner: conf.attributes.owner, + })) + ); + + if (myCaseConfigure.saved_objects.length > 0) { + await Promise.all( + myCaseConfigure.saved_objects.map((cc) => + caseConfigureService.delete({ unsecuredSavedObjectsClient, configurationId: cc.id }) + ) + ); + } + + const savedObjectID = SavedObjectsUtils.generateId(); + + await authorization.ensureAuthorized({ + operation: Operations.createConfiguration, + entities: [{ owner: configuration.owner, id: savedObjectID }], + }); + + const creationDate = new Date().toISOString(); + let mappings: ConnectorMappingsAttributes[] = []; + + try { + mappings = await casesClientInternal.configuration.createMappings({ + connectorId: configuration.connector.id, + connectorType: configuration.connector.type, + owner: configuration.owner, + }); + } catch (e) { + error = e.isBoom + ? e.output.payload.message + : `Error connecting to ${configuration.connector.name} instance`; + } + + const post = await caseConfigureService.post({ + unsecuredSavedObjectsClient, + attributes: { + ...configuration, + connector: transformCaseConnectorToEsConnector(configuration.connector), + created_at: creationDate, + created_by: user, + updated_at: null, + updated_by: null, + }, + id: savedObjectID, + }); + + return CaseConfigureResponseRt.encode({ + ...post.attributes, + // Reserve for future implementations + connector: transformESConnectorToCaseConnector(post.attributes.connector), + mappings, + version: post.version ?? '', + error, + id: post.id, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to create case configuration: ${error}`, + error, + logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/client/configure/create_mappings.ts b/x-pack/plugins/cases/server/client/configure/create_mappings.ts new file mode 100644 index 0000000000000..b01f10d7a9e43 --- /dev/null +++ b/x-pack/plugins/cases/server/client/configure/create_mappings.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { createCaseError } from '../../common/error'; +import { CasesClientArgs, CasesClientInternal } from '..'; +import { CreateMappingsArgs } from './types'; + +export const createMappings = async ( + { connectorType, connectorId, owner }: CreateMappingsArgs, + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise => { + const { unsecuredSavedObjectsClient, connectorMappingsService, logger } = clientArgs; + + try { + if (connectorType === ConnectorTypes.none) { + return []; + } + + const res = await casesClientInternal.configuration.getFields({ + connectorId, + connectorType, + }); + + const theMapping = await connectorMappingsService.post({ + unsecuredSavedObjectsClient, + attributes: { + mappings: res.defaultMappings, + owner, + }, + references: [ + { + type: ACTION_SAVED_OBJECT_TYPE, + name: `associated-${ACTION_SAVED_OBJECT_TYPE}`, + id: connectorId, + }, + ], + }); + + return theMapping.attributes.mappings; + } catch (error) { + throw createCaseError({ + message: `Failed to create mapping connector id: ${connectorId} type: ${connectorType}: ${error}`, + error, + logger, + }); + } +}; diff --git a/x-pack/plugins/cases/server/client/configure/get_fields.test.ts b/x-pack/plugins/cases/server/client/configure/get_fields.test.ts deleted file mode 100644 index c474361293da4..0000000000000 --- a/x-pack/plugins/cases/server/client/configure/get_fields.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { ConnectorTypes } from '../../../common'; - -import { createMockSavedObjectsRepository, mockCaseMappings } from '../../routes/api/__fixtures__'; -import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; -import { actionsClientMock } from '../../../../actions/server/actions_client.mock'; -import { actionsErrResponse, mappings, mockGetFieldsResponse } from './mock'; -describe('get_fields', () => { - const execute = jest.fn().mockReturnValue(mockGetFieldsResponse); - const actionsMock = { ...actionsClientMock.create(), execute }; - beforeEach(async () => { - jest.clearAllMocks(); - }); - - describe('happy path', () => { - test('it gets fields', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseMappingsSavedObject: mockCaseMappings, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.getFields({ - actionsClient: actionsMock, - connectorType: ConnectorTypes.jira, - connectorId: '123', - }); - expect(res).toEqual({ - fields: [ - { id: 'summary', name: 'Summary', required: true, type: 'text' }, - { id: 'description', name: 'Description', required: false, type: 'text' }, - ], - defaultMappings: mappings[ConnectorTypes.jira], - }); - }); - }); - - describe('unhappy path', () => { - test('it throws error', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseMappingsSavedObject: mockCaseMappings, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - await casesClient.client - .getFields({ - actionsClient: { ...actionsMock, execute: jest.fn().mockReturnValue(actionsErrResponse) }, - connectorType: ConnectorTypes.jira, - connectorId: '123', - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(424); - }); - }); - }); -}); diff --git a/x-pack/plugins/cases/server/client/configure/get_fields.ts b/x-pack/plugins/cases/server/client/configure/get_fields.ts index 8d899f0df1a76..78627cfaca6ed 100644 --- a/x-pack/plugins/cases/server/client/configure/get_fields.ts +++ b/x-pack/plugins/cases/server/client/configure/get_fields.ts @@ -7,15 +7,20 @@ import Boom from '@hapi/boom'; -import { GetFieldsResponse } from '../../../common'; -import { ConfigureFields } from '../types'; +import { GetFieldsResponse } from '../../../common/api'; import { createDefaultMapping, formatFields } from './utils'; +import { CasesClientArgs } from '..'; -export const getFields = async ({ - actionsClient, - connectorType, - connectorId, -}: ConfigureFields): Promise => { +interface ConfigurationGetFields { + connectorId: string; + connectorType: string; +} + +export const getFields = async ( + { connectorType, connectorId }: ConfigurationGetFields, + clientArgs: CasesClientArgs +): Promise => { + const { actionsClient } = clientArgs; const results = await actionsClient.execute({ actionId: connectorId, params: { diff --git a/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts b/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts deleted file mode 100644 index 8f75e60260873..0000000000000 --- a/x-pack/plugins/cases/server/client/configure/get_mappings.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { ConnectorTypes } from '../../../common'; - -import { - createMockSavedObjectsRepository, - mockCaseMappingsResilient, - mockCaseMappingsBad, -} from '../../routes/api/__fixtures__'; -import { createCasesClientWithMockSavedObjectsClient } from '../mocks'; -import { actionsClientMock } from '../../../../actions/server/actions_client.mock'; -import { mappings, mockGetFieldsResponse } from './mock'; - -describe('get_mappings', () => { - const execute = jest.fn().mockReturnValue(mockGetFieldsResponse); - const actionsMock = { ...actionsClientMock.create(), execute }; - beforeEach(async () => { - jest.restoreAllMocks(); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), - })); - }); - - describe('happy path', () => { - test('it gets existing mappings', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseMappingsSavedObject: mockCaseMappingsResilient, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.getMappings({ - actionsClient: actionsMock, - connectorType: ConnectorTypes.jira, - connectorId: '123', - }); - - expect(res).toEqual(mappings[ConnectorTypes.resilient]); - }); - test('it creates new mappings', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseMappingsSavedObject: [], - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.getMappings({ - actionsClient: actionsMock, - connectorType: ConnectorTypes.jira, - connectorId: '123', - }); - - expect(res).toEqual(mappings[ConnectorTypes.jira]); - }); - }); - describe('unhappy path', () => { - test('it gets existing mappings, but attributes object is empty so it creates new mappings', async () => { - const savedObjectsClient = createMockSavedObjectsRepository({ - caseMappingsSavedObject: mockCaseMappingsBad, - }); - const casesClient = await createCasesClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await casesClient.client.getMappings({ - actionsClient: actionsMock, - connectorType: ConnectorTypes.jira, - connectorId: '123', - }); - - expect(res).toEqual(mappings[ConnectorTypes.jira]); - }); - }); -}); diff --git a/x-pack/plugins/cases/server/client/configure/get_mappings.ts b/x-pack/plugins/cases/server/client/configure/get_mappings.ts index b58a703455f6a..3489c06b1da5a 100644 --- a/x-pack/plugins/cases/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/cases/server/client/configure/get_mappings.ts @@ -5,39 +5,26 @@ * 2.0. */ -import { SavedObjectsClientContract, Logger } from 'src/core/server'; -import { ActionsClient } from '../../../../actions/server'; -import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common'; +import { SavedObjectsFindResponse } from 'kibana/server'; +import { ConnectorMappings, ConnectorTypes } from '../../../common/api'; import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; -import { ConnectorMappingsServiceSetup } from '../../services'; -import { CasesClientHandler } from '..'; import { createCaseError } from '../../common/error'; +import { CasesClientArgs } from '..'; +import { MappingsArgs } from './types'; -interface GetMappingsArgs { - savedObjectsClient: SavedObjectsClientContract; - connectorMappingsService: ConnectorMappingsServiceSetup; - actionsClient: ActionsClient; - casesClient: CasesClientHandler; - connectorType: string; - connectorId: string; - logger: Logger; -} +export const getMappings = async ( + { connectorType, connectorId }: MappingsArgs, + clientArgs: CasesClientArgs +): Promise['saved_objects']> => { + const { unsecuredSavedObjectsClient, connectorMappingsService, logger } = clientArgs; -export const getMappings = async ({ - savedObjectsClient, - connectorMappingsService, - actionsClient, - casesClient, - connectorType, - connectorId, - logger, -}: GetMappingsArgs): Promise => { try { if (connectorType === ConnectorTypes.none) { return []; } + const myConnectorMappings = await connectorMappingsService.find({ - client: savedObjectsClient, + unsecuredSavedObjectsClient, options: { hasReference: { type: ACTION_SAVED_OBJECT_TYPE, @@ -45,35 +32,8 @@ export const getMappings = async ({ }, }, }); - let theMapping; - // Create connector mappings if there are none - if ( - myConnectorMappings.total === 0 || - (myConnectorMappings.total > 0 && - !myConnectorMappings.saved_objects[0].attributes.hasOwnProperty('mappings')) - ) { - const res = await casesClient.getFields({ - actionsClient, - connectorId, - connectorType, - }); - theMapping = await connectorMappingsService.post({ - client: savedObjectsClient, - attributes: { - mappings: res.defaultMappings, - }, - references: [ - { - type: ACTION_SAVED_OBJECT_TYPE, - name: `associated-${ACTION_SAVED_OBJECT_TYPE}`, - id: connectorId, - }, - ], - }); - } else { - theMapping = myConnectorMappings.saved_objects[0]; - } - return theMapping ? theMapping.attributes.mappings : []; + + return myConnectorMappings.saved_objects; } catch (error) { throw createCaseError({ message: `Failed to retrieve mapping connector id: ${connectorId} type: ${connectorType}: ${error}`, diff --git a/x-pack/plugins/cases/server/client/configure/types.ts b/x-pack/plugins/cases/server/client/configure/types.ts new file mode 100644 index 0000000000000..a34251690db48 --- /dev/null +++ b/x-pack/plugins/cases/server/client/configure/types.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. + */ + +export interface MappingsArgs { + connectorType: string; + connectorId: string; +} + +export interface CreateMappingsArgs extends MappingsArgs { + owner: string; +} + +export interface UpdateMappingsArgs extends MappingsArgs { + mappingId: string; +} + +export interface ConfigurationGetFields { + connectorId: string; + connectorType: string; +} diff --git a/x-pack/plugins/cases/server/client/configure/update_mappings.ts b/x-pack/plugins/cases/server/client/configure/update_mappings.ts new file mode 100644 index 0000000000000..7eccf4cbbe582 --- /dev/null +++ b/x-pack/plugins/cases/server/client/configure/update_mappings.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { createCaseError } from '../../common/error'; +import { CasesClientArgs, CasesClientInternal } from '..'; +import { UpdateMappingsArgs } from './types'; + +export const updateMappings = async ( + { connectorType, connectorId, mappingId }: UpdateMappingsArgs, + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): Promise => { + const { unsecuredSavedObjectsClient, connectorMappingsService, logger } = clientArgs; + + try { + if (connectorType === ConnectorTypes.none) { + return []; + } + + const res = await casesClientInternal.configuration.getFields({ + connectorId, + connectorType, + }); + + const theMapping = await connectorMappingsService.update({ + unsecuredSavedObjectsClient, + mappingId, + attributes: { + mappings: res.defaultMappings, + }, + references: [ + { + type: ACTION_SAVED_OBJECT_TYPE, + name: `associated-${ACTION_SAVED_OBJECT_TYPE}`, + id: connectorId, + }, + ], + }); + + return theMapping.attributes.mappings ?? []; + } catch (error) { + throw createCaseError({ + message: `Failed to create mapping connector id: ${connectorId} type: ${connectorType}: ${error}`, + error, + logger, + }); + } +}; diff --git a/x-pack/plugins/cases/server/client/factory.ts b/x-pack/plugins/cases/server/client/factory.ts new file mode 100644 index 0000000000000..4644efb61916f --- /dev/null +++ b/x-pack/plugins/cases/server/client/factory.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { + KibanaRequest, + SavedObjectsServiceStart, + Logger, + ElasticsearchClient, +} from 'kibana/server'; +import { SecurityPluginSetup, SecurityPluginStart } from '../../../security/server'; +import { SAVED_OBJECT_TYPES } from '../../common/constants'; +import { Authorization } from '../authorization/authorization'; +import { GetSpaceFn } from '../authorization/types'; +import { + CaseConfigureService, + CasesService, + CaseUserActionService, + ConnectorMappingsService, + AttachmentService, + AlertService, +} from '../services'; +import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; +import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; +import { AuthorizationAuditLogger } from '../authorization'; +import { CasesClient, createCasesClient } from '.'; + +interface CasesClientFactoryArgs { + securityPluginSetup?: SecurityPluginSetup; + securityPluginStart?: SecurityPluginStart; + getSpace: GetSpaceFn; + featuresPluginStart: FeaturesPluginStart; + actionsPluginStart: ActionsPluginStart; +} + +/** + * This class handles the logic for creating a CasesClient. We need this because some of the member variables + * can't be initialized until a plugin's start() method but we need to register the case context in the setup() method. + */ +export class CasesClientFactory { + private isInitialized = false; + private readonly logger: Logger; + private options?: CasesClientFactoryArgs; + + constructor(logger: Logger) { + this.logger = logger; + } + + /** + * This should be called by the plugin's start() method. + */ + public initialize(options: CasesClientFactoryArgs) { + if (this.isInitialized) { + throw new Error('CasesClientFactory already initialized'); + } + this.isInitialized = true; + this.options = options; + } + + /** + * Creates a cases client for the current request. This request will be used to authorize the operations done through + * the client. + */ + public async create({ + request, + scopedClusterClient, + savedObjectsService, + }: { + request: KibanaRequest; + savedObjectsService: SavedObjectsServiceStart; + scopedClusterClient: ElasticsearchClient; + }): Promise { + if (!this.isInitialized || !this.options) { + throw new Error('CasesClientFactory must be initialized before calling create'); + } + + const auditLogger = this.options.securityPluginSetup?.audit.asScoped(request); + + const auth = await Authorization.create({ + request, + securityAuth: this.options.securityPluginStart?.authz, + getSpace: this.options.getSpace, + features: this.options.featuresPluginStart, + auditLogger: new AuthorizationAuditLogger(auditLogger), + logger: this.logger, + }); + + const caseService = new CasesService(this.logger, this.options?.securityPluginStart?.authc); + const userInfo = caseService.getUser({ request }); + + return createCasesClient({ + alertsService: new AlertService(), + scopedClusterClient, + unsecuredSavedObjectsClient: savedObjectsService.getScopedClient(request, { + includedHiddenTypes: SAVED_OBJECT_TYPES, + // this tells the security plugin to not perform SO authorization and audit logging since we are handling + // that manually using our Authorization class and audit logger. + excludedWrappers: ['security'], + }), + // We only want these fields from the userInfo object + user: { username: userInfo.username, email: userInfo.email, full_name: userInfo.full_name }, + caseService, + caseConfigureService: new CaseConfigureService(this.logger), + connectorMappingsService: new ConnectorMappingsService(this.logger), + userActionService: new CaseUserActionService(this.logger), + attachmentService: new AttachmentService(this.logger), + logger: this.logger, + authorization: auth, + actionsClient: await this.options.actionsPluginStart.getActionsClientWithRequest(request), + }); + } +} diff --git a/x-pack/plugins/cases/server/client/index.test.ts b/x-pack/plugins/cases/server/client/index.test.ts deleted file mode 100644 index cfb30d6d5bcb6..0000000000000 --- a/x-pack/plugins/cases/server/client/index.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { - elasticsearchServiceMock, - loggingSystemMock, - savedObjectsClientMock, -} from '../../../../../src/core/server/mocks'; -import { nullUser } from '../common'; -import { - connectorMappingsServiceMock, - createCaseServiceMock, - createConfigureServiceMock, - createUserActionServiceMock, - createAlertServiceMock, -} from '../services/mocks'; - -jest.mock('./client'); -import { CasesClientHandler } from './client'; -import { createExternalCasesClient } from './index'; - -const logger = loggingSystemMock.create().get('case'); -const esClient = elasticsearchServiceMock.createElasticsearchClient(); -const caseConfigureService = createConfigureServiceMock(); -const alertsService = createAlertServiceMock(); -const caseService = createCaseServiceMock(); -const connectorMappingsService = connectorMappingsServiceMock(); -const savedObjectsClient = savedObjectsClientMock.create(); -const userActionService = createUserActionServiceMock(); - -describe('createExternalCasesClient()', () => { - test('it creates the client correctly', async () => { - createExternalCasesClient({ - scopedClusterClient: esClient, - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - user: nullUser, - savedObjectsClient, - userActionService, - logger, - }); - expect(CasesClientHandler).toHaveBeenCalledTimes(1); - }); -}); diff --git a/x-pack/plugins/cases/server/client/index.ts b/x-pack/plugins/cases/server/client/index.ts index fd7cae0edd2ea..7904e65ca6276 100644 --- a/x-pack/plugins/cases/server/client/index.ts +++ b/x-pack/plugins/cases/server/client/index.ts @@ -5,16 +5,8 @@ * 2.0. */ -import { CasesClientFactoryArguments, CasesClient } from './types'; -import { CasesClientHandler } from './client'; - -export { CasesClientHandler } from './client'; -export { CasesClient } from './types'; - -/** - * Create a CasesClientHandler to external services (other plugins). - */ -export const createExternalCasesClient = (clientArgs: CasesClientFactoryArguments): CasesClient => { - const client = new CasesClientHandler(clientArgs); - return client; -}; +export { CasesClient } from './client'; +export { CasesClientInternal } from './client_internal'; +export { CasesClientArgs } from './types'; +export { createCasesClient } from './client'; +export { createCasesClientInternal } from './client_internal'; diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index 4c0f89cf77a67..10b298995f87a 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -5,86 +5,111 @@ * 2.0. */ -import { ElasticsearchClient } from 'kibana/server'; -import { DeeplyMockedKeys } from '@kbn/utility-types/target/jest'; -import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; -import { - AlertServiceContract, - CaseConfigureService, - CaseService, - CaseUserActionServiceSetup, - ConnectorMappingsService, -} from '../services'; -import { CasesClient } from './types'; -import { authenticationMock } from '../routes/api/__fixtures__'; -import { createExternalCasesClient } from '.'; - -export type CasesClientPluginContractMock = jest.Mocked; -export const createExternalCasesClientMock = (): CasesClientPluginContractMock => ({ - addComment: jest.fn(), - create: jest.fn(), - get: jest.fn(), - push: jest.fn(), - getAlerts: jest.fn(), - getFields: jest.fn(), - getMappings: jest.fn(), - getUserActions: jest.fn(), - update: jest.fn(), - updateAlertsStatus: jest.fn(), -}); - -export const createCasesClientWithMockSavedObjectsClient = async ({ - savedObjectsClient, - badAuth = false, - omitFromContext = [], -}: { - savedObjectsClient: any; - badAuth?: boolean; - omitFromContext?: string[]; -}): Promise<{ - client: CasesClient; - services: { - userActionService: jest.Mocked; - alertsService: jest.Mocked; +import { PublicContract, PublicMethodsOf } from '@kbn/utility-types'; + +import { CasesClient } from '.'; +import { AttachmentsSubClient } from './attachments/client'; +import { CasesSubClient } from './cases/client'; +import { ConfigureSubClient } from './configure/client'; +import { CasesClientFactory } from './factory'; +import { StatsSubClient } from './stats/client'; +import { SubCasesClient } from './sub_cases/client'; +import { UserActionsSubClient } from './user_actions/client'; + +type CasesSubClientMock = jest.Mocked; + +const createCasesSubClientMock = (): CasesSubClientMock => { + return { + create: jest.fn(), + find: jest.fn(), + get: jest.fn(), + push: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + getTags: jest.fn(), + getReporters: jest.fn(), + getCaseIDsByAlertID: jest.fn(), }; - esClient: DeeplyMockedKeys; -}> => { - const esClient = elasticsearchServiceMock.createElasticsearchClient(); - const log = loggingSystemMock.create().get('case'); - - const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); - const caseService = new CaseService(log, auth); - const caseConfigureServicePlugin = new CaseConfigureService(log); - const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - - const caseConfigureService = await caseConfigureServicePlugin.setup(); - - const connectorMappingsService = await connectorMappingsServicePlugin.setup(); - const userActionService = { - getUserActions: jest.fn(), - postUserActions: jest.fn(), +}; + +type AttachmentsSubClientMock = jest.Mocked; + +const createAttachmentsSubClientMock = (): AttachmentsSubClientMock => { + return { + add: jest.fn(), + deleteAll: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + getAll: jest.fn(), + get: jest.fn(), + update: jest.fn(), }; +}; - const alertsService = { - initialize: jest.fn(), - updateAlertsStatus: jest.fn(), - getAlerts: jest.fn(), +type UserActionsSubClientMock = jest.Mocked; + +const createUserActionsSubClientMock = (): UserActionsSubClientMock => { + return { + getAll: jest.fn(), + }; +}; + +type SubCasesClientMock = jest.Mocked; + +const createSubCasesClientMock = (): SubCasesClientMock => { + return { + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), }; +}; + +type ConfigureSubClientMock = jest.Mocked; - const casesClient = createExternalCasesClient({ - savedObjectsClient, - user: auth.getCurrentUser(), - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, - scopedClusterClient: esClient, - logger: log, - }); +const createConfigureSubClientMock = (): ConfigureSubClientMock => { return { - client: casesClient, - services: { userActionService, alertsService }, - esClient, + get: jest.fn(), + getConnectors: jest.fn(), + update: jest.fn(), + create: jest.fn(), }; }; + +type StatsSubClientMock = jest.Mocked; + +const createStatsSubClientMock = (): StatsSubClientMock => { + return { + getStatusTotalsByType: jest.fn(), + }; +}; + +export interface CasesClientMock extends CasesClient { + cases: CasesSubClientMock; + attachments: AttachmentsSubClientMock; + userActions: UserActionsSubClientMock; + subCases: SubCasesClientMock; +} + +export const createCasesClientMock = (): CasesClientMock => { + const client: PublicContract = { + cases: createCasesSubClientMock(), + attachments: createAttachmentsSubClientMock(), + userActions: createUserActionsSubClientMock(), + subCases: createSubCasesClientMock(), + configure: createConfigureSubClientMock(), + stats: createStatsSubClientMock(), + }; + return (client as unknown) as CasesClientMock; +}; + +export type CasesClientFactoryMock = jest.Mocked; + +export const createCasesClientFactory = (): CasesClientFactoryMock => { + const factory: PublicMethodsOf = { + initialize: jest.fn(), + create: jest.fn(), + }; + + return (factory as unknown) as CasesClientFactoryMock; +}; diff --git a/x-pack/plugins/cases/server/client/stats/client.ts b/x-pack/plugins/cases/server/client/stats/client.ts new file mode 100644 index 0000000000000..0e222d54ab218 --- /dev/null +++ b/x-pack/plugins/cases/server/client/stats/client.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { CasesClientArgs } from '..'; +import { + CasesStatusRequest, + CasesStatusResponse, + CasesStatusResponseRt, + caseStatuses, + throwErrors, + excess, + CasesStatusRequestRt, +} from '../../../common/api'; +import { Operations } from '../../authorization'; +import { createCaseError } from '../../common/error'; +import { constructQueryOptions } from '../utils'; + +/** + * Statistics API contract. + */ +export interface StatsSubClient { + /** + * Retrieves the total number of open, closed, and in-progress cases. + */ + getStatusTotalsByType(params: CasesStatusRequest): Promise; +} + +/** + * Creates the interface for retrieving the number of open, closed, and in progress cases. + * + * @ignore + */ +export function createStatsSubClient(clientArgs: CasesClientArgs): StatsSubClient { + return Object.freeze({ + getStatusTotalsByType: (params: CasesStatusRequest) => + getStatusTotalsByType(params, clientArgs), + }); +} + +async function getStatusTotalsByType( + params: CasesStatusRequest, + clientArgs: CasesClientArgs +): Promise { + const { unsecuredSavedObjectsClient, caseService, logger, authorization } = clientArgs; + + try { + const queryParams = pipe( + excess(CasesStatusRequestRt).decode(params), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { + filter: authorizationFilter, + ensureSavedObjectsAreAuthorized, + } = await authorization.getAuthorizationFilter(Operations.getCaseStatuses); + + const [openCases, inProgressCases, closedCases] = await Promise.all([ + ...caseStatuses.map((status) => { + const statusQuery = constructQueryOptions({ + owner: queryParams.owner, + status, + authorizationFilter, + }); + return caseService.findCaseStatusStats({ + unsecuredSavedObjectsClient, + caseOptions: statusQuery.case, + subCaseOptions: statusQuery.subCase, + ensureSavedObjectsAreAuthorized, + }); + }), + ]); + + return CasesStatusResponseRt.encode({ + count_open_cases: openCases, + count_in_progress_cases: inProgressCases, + count_closed_cases: closedCases, + }); + } catch (error) { + throw createCaseError({ message: `Failed to get status stats: ${error}`, error, logger }); + } +} diff --git a/x-pack/plugins/cases/server/client/sub_cases/client.ts b/x-pack/plugins/cases/server/client/sub_cases/client.ts new file mode 100644 index 0000000000000..b35d58ce06010 --- /dev/null +++ b/x-pack/plugins/cases/server/client/sub_cases/client.ts @@ -0,0 +1,265 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { + caseStatuses, + SubCaseResponse, + SubCaseResponseRt, + SubCasesFindRequest, + SubCasesFindResponse, + SubCasesFindResponseRt, + SubCasesPatchRequest, +} from '../../../common/api'; +import { CasesClientArgs, CasesClientInternal } from '..'; +import { countAlertsForID, flattenSubCaseSavedObject, transformSubCases } from '../../common'; +import { createCaseError } from '../../common/error'; +import { CASE_SAVED_OBJECT } from '../../../common/constants'; +import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; +import { constructQueryOptions } from '../utils'; +import { defaultPage, defaultPerPage } from '../../routes/api'; +import { update } from './update'; +import { ISubCaseResponse, ISubCasesFindResponse, ISubCasesResponse } from '../typedoc_interfaces'; + +interface FindArgs { + /** + * The case ID for finding associated sub cases + */ + caseID: string; + /** + * Options for filtering the returned sub cases + */ + queryParams: SubCasesFindRequest; +} + +interface GetArgs { + /** + * A flag to include the attachments with the results + */ + includeComments: boolean; + /** + * The ID of the sub case to retrieve + */ + id: string; +} + +/** + * The API routes for interacting with sub cases. + * + * @public + */ +export interface SubCasesClient { + /** + * Deletes the specified entities and their attachments. + */ + delete(ids: string[]): Promise; + /** + * Retrieves the sub cases matching the search criteria. + */ + find(findArgs: FindArgs): Promise; + /** + * Retrieves a single sub case. + */ + get(getArgs: GetArgs): Promise; + /** + * Updates the specified sub cases to the new values included in the request. + */ + update(subCases: SubCasesPatchRequest): Promise; +} + +/** + * Creates a client for handling the different exposed API routes for interacting with sub cases. + * + * @ignore + */ +export function createSubCasesClient( + clientArgs: CasesClientArgs, + casesClientInternal: CasesClientInternal +): SubCasesClient { + return Object.freeze({ + delete: (ids: string[]) => deleteSubCase(ids, clientArgs), + find: (findArgs: FindArgs) => find(findArgs, clientArgs), + get: (getArgs: GetArgs) => get(getArgs, clientArgs), + update: (subCases: SubCasesPatchRequest) => + update({ subCases, clientArgs, casesClientInternal }), + }); +} + +async function deleteSubCase(ids: string[], clientArgs: CasesClientArgs): Promise { + try { + const { + unsecuredSavedObjectsClient, + user, + userActionService, + caseService, + attachmentService, + } = clientArgs; + + const [comments, subCases] = await Promise.all([ + caseService.getAllSubCaseComments({ unsecuredSavedObjectsClient, id: ids }), + caseService.getSubCases({ unsecuredSavedObjectsClient, ids }), + ]); + + const subCaseErrors = subCases.saved_objects.filter((subCase) => subCase.error !== undefined); + + if (subCaseErrors.length > 0) { + throw Boom.notFound( + `These sub cases ${subCaseErrors + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + const subCaseIDToParentID = subCases.saved_objects.reduce((acc, subCase) => { + const parentID = subCase.references.find((ref) => ref.type === CASE_SAVED_OBJECT); + acc.set(subCase.id, parentID?.id); + return acc; + }, new Map()); + + await Promise.all( + comments.saved_objects.map((comment) => + attachmentService.delete({ unsecuredSavedObjectsClient, attachmentId: comment.id }) + ) + ); + + await Promise.all(ids.map((id) => caseService.deleteSubCase(unsecuredSavedObjectsClient, id))); + + const deleteDate = new Date().toISOString(); + + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, + actions: subCases.saved_objects.map((subCase) => + buildCaseUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: user, + // if for some reason the sub case didn't have a reference to its parent, we'll still log a user action + // but we won't have the case ID + caseId: subCaseIDToParentID.get(subCase.id) ?? '', + subCaseId: subCase.id, + fields: ['sub_case', 'comment', 'status'], + owner: subCase.attributes.owner, + }) + ), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to delete sub cases ids: ${JSON.stringify(ids)}: ${error}`, + error, + logger: clientArgs.logger, + }); + } +} + +async function find( + { caseID, queryParams }: FindArgs, + clientArgs: CasesClientArgs +): Promise { + try { + const { unsecuredSavedObjectsClient, caseService } = clientArgs; + + const ids = [caseID]; + const { subCase: subCaseQueryOptions } = constructQueryOptions({ + status: queryParams.status, + sortByField: queryParams.sortField, + }); + + const subCases = await caseService.findSubCasesGroupByCase({ + unsecuredSavedObjectsClient, + ids, + options: { + sortField: 'created_at', + page: defaultPage, + perPage: defaultPerPage, + ...queryParams, + ...subCaseQueryOptions, + }, + }); + + const [open, inProgress, closed] = await Promise.all([ + ...caseStatuses.map((status) => { + const { subCase: statusQueryOptions } = constructQueryOptions({ + status, + sortByField: queryParams.sortField, + }); + return caseService.findSubCaseStatusStats({ + unsecuredSavedObjectsClient, + options: statusQueryOptions ?? {}, + ids, + }); + }), + ]); + + return SubCasesFindResponseRt.encode( + transformSubCases({ + page: subCases.page, + perPage: subCases.perPage, + total: subCases.total, + subCasesMap: subCases.subCasesMap, + open, + inProgress, + closed, + }) + ); + } catch (error) { + throw createCaseError({ + message: `Failed to find sub cases for case id: ${caseID}: ${error}`, + error, + logger: clientArgs.logger, + }); + } +} + +async function get( + { includeComments, id }: GetArgs, + clientArgs: CasesClientArgs +): Promise { + try { + const { unsecuredSavedObjectsClient, caseService } = clientArgs; + + const subCase = await caseService.getSubCase({ + unsecuredSavedObjectsClient, + id, + }); + + if (!includeComments) { + return SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + }) + ); + } + + const theComments = await caseService.getAllSubCaseComments({ + unsecuredSavedObjectsClient, + id, + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, + }); + + return SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + totalAlerts: countAlertsForID({ + comments: theComments, + id, + }), + }) + ); + } catch (error) { + throw createCaseError({ + message: `Failed to get sub case id: ${id}: ${error}`, + error, + logger: clientArgs.logger, + }); + } +} diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/cases/server/client/sub_cases/update.ts similarity index 77% rename from x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts rename to x-pack/plugins/cases/server/client/sub_cases/update.ts index 0b142fb5279e5..b49d36d7a27d4 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/patch_sub_cases.ts +++ b/x-pack/plugins/cases/server/client/sub_cases/update.ts @@ -11,15 +11,13 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { SavedObjectsClientContract, - KibanaRequest, SavedObject, SavedObjectsFindResponse, Logger, } from 'kibana/server'; -import { CasesClient } from '../../../../client'; -import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; -import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../../../services'; +import { nodeBuilder } from '../../../../../../src/plugins/data/common'; +import { CasesService } from '../../services'; import { CaseStatuses, SubCasesPatchRequest, @@ -35,30 +33,19 @@ import { SubCasesResponseRt, User, CommentAttributes, -} from '../../../../../common'; -import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common'; -import { RouteDeps } from '../../types'; +} from '../../../common/api'; +import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../common/constants'; +import { getCaseToUpdate } from '../utils'; +import { buildSubCaseUserActions } from '../../services/user_actions/helpers'; import { - escapeHatch, - flattenSubCaseSavedObject, + createAlertUpdateRequest, isCommentRequestTypeAlertOrGenAlert, - wrapError, -} from '../../utils'; -import { getCaseToUpdate } from '../helpers'; -import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; -import { createAlertUpdateRequest } from '../../../../common'; -import { UpdateAlertRequest } from '../../../../client/types'; -import { createCaseError } from '../../../../common/error'; - -interface UpdateArgs { - client: SavedObjectsClientContract; - caseService: CaseServiceSetup; - userActionService: CaseUserActionServiceSetup; - request: KibanaRequest; - casesClient: CasesClient; - subCases: SubCasesPatchRequest; - logger: Logger; -} + flattenSubCaseSavedObject, +} from '../../common'; +import { createCaseError } from '../../common/error'; +import { UpdateAlertRequest } from '../../client/alerts/client'; +import { CasesClientArgs } from '../types'; +import { CasesClientInternal } from '../client_internal'; function checkNonExistingOrConflict( toUpdate: SubCasePatchRequest[], @@ -128,19 +115,19 @@ function getParentIDs({ async function getParentCases({ caseService, - client, + unsecuredSavedObjectsClient, subCaseIDs, subCasesMap, }: { - caseService: CaseServiceSetup; - client: SavedObjectsClientContract; + caseService: CasesService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; subCaseIDs: string[]; subCasesMap: Map>; }): Promise>> { const parentIDInfo = getParentIDs({ subCaseIDs, subCasesMap }); const parentCases = await caseService.getCases({ - client, + unsecuredSavedObjectsClient, caseIds: parentIDInfo.ids, }); @@ -195,18 +182,21 @@ function getID(comment: SavedObject): string | undefined { async function getAlertComments({ subCasesToSync, caseService, - client, + unsecuredSavedObjectsClient, }: { subCasesToSync: SubCasePatchRequest[]; - caseService: CaseServiceSetup; - client: SavedObjectsClientContract; + caseService: CasesService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; }): Promise> { const ids = subCasesToSync.map((subCase) => subCase.id); return caseService.getAllSubCaseComments({ - client, + unsecuredSavedObjectsClient, id: ids, options: { - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + filter: nodeBuilder.or([ + nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.alert), + nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.generatedAlert), + ]), }, }); } @@ -215,17 +205,17 @@ async function getAlertComments({ * Updates the status of alerts for the specified sub cases. */ async function updateAlerts({ - subCasesToSync, caseService, - client, - casesClient, + unsecuredSavedObjectsClient, + casesClientInternal, logger, + subCasesToSync, }: { - subCasesToSync: SubCasePatchRequest[]; - caseService: CaseServiceSetup; - client: SavedObjectsClientContract; - casesClient: CasesClient; + caseService: CasesService; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + casesClientInternal: CasesClientInternal; logger: Logger; + subCasesToSync: SubCasePatchRequest[]; }) { try { const subCasesToSyncMap = subCasesToSync.reduce((acc, subCase) => { @@ -233,7 +223,11 @@ async function updateAlerts({ return acc; }, new Map()); // get all the alerts for all sub cases that need to be synced - const totalAlerts = await getAlertComments({ caseService, client, subCasesToSync }); + const totalAlerts = await getAlertComments({ + caseService, + unsecuredSavedObjectsClient, + subCasesToSync, + }); // create a map of the status (open, closed, etc) to alert info that needs to be updated const alertsToUpdate = totalAlerts.saved_objects.reduce( (acc: UpdateAlertRequest[], alertComment) => { @@ -251,7 +245,7 @@ async function updateAlerts({ [] ); - await casesClient.updateAlertsStatus({ alerts: alertsToUpdate }); + await casesClientInternal.alerts.updateStatus({ alerts: alertsToUpdate }); } catch (error) { throw createCaseError({ message: `Failed to update alert status while updating sub cases: ${JSON.stringify( @@ -263,23 +257,28 @@ async function updateAlerts({ } } -async function update({ - client, - caseService, - userActionService, - request, - casesClient, +/** + * Handles updating the fields in a sub case. + */ +export async function update({ subCases, - logger, -}: UpdateArgs): Promise { + clientArgs, + casesClientInternal, +}: { + subCases: SubCasesPatchRequest; + clientArgs: CasesClientArgs; + casesClientInternal: CasesClientInternal; +}): Promise { const query = pipe( excess(SubCasesPatchRequestRt).decode(subCases), fold(throwErrors(Boom.badRequest), identity) ); try { + const { unsecuredSavedObjectsClient, user, caseService, userActionService } = clientArgs; + const bulkSubCases = await caseService.getSubCases({ - client, + unsecuredSavedObjectsClient, ids: query.subCases.map((q) => q.id), }); @@ -297,17 +296,15 @@ async function update({ } const subIDToParentCase = await getParentCases({ - client, + unsecuredSavedObjectsClient, caseService, subCaseIDs: nonEmptySubCaseRequests.map((subCase) => subCase.id), subCasesMap, }); - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); const updatedAt = new Date().toISOString(); const updatedCases = await caseService.patchSubCases({ - client, + unsecuredSavedObjectsClient, subCases: nonEmptySubCaseRequests.map((thisCase) => { const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; let closedInfo: { closed_at: string | null; closed_by: User | null } = { @@ -321,7 +318,7 @@ async function update({ ) { closedInfo = { closed_at: updatedAt, - closed_by: { email, full_name, username }, + closed_by: user, }; } else if ( updateSubCaseAttributes.status && @@ -339,7 +336,7 @@ async function update({ ...updateSubCaseAttributes, ...closedInfo, updated_at: updatedAt, - updated_by: { email, full_name, username }, + updated_by: user, }, version, }; @@ -359,10 +356,10 @@ async function update({ await updateAlerts({ caseService, - client, - casesClient, + unsecuredSavedObjectsClient, + casesClientInternal, subCasesToSync: subCasesToSyncAlertsFor, - logger, + logger: clientArgs.logger, }); const returnUpdatedSubCases = updatedCases.saved_objects.reduce( @@ -386,13 +383,13 @@ async function update({ [] ); - await userActionService.postUserActions({ - client, + await userActionService.bulkCreate({ + unsecuredSavedObjectsClient, actions: buildSubCaseUserActions({ originalSubCases: bulkSubCases.saved_objects, updatedSubCases: updatedCases.saved_objects, actionDate: updatedAt, - actionBy: { email, full_name, username }, + actionBy: user, }), }); @@ -405,44 +402,7 @@ async function update({ throw createCaseError({ message: `Failed to update sub cases: ${JSON.stringify(idVersions)}: ${error}`, error, - logger, + logger: clientArgs.logger, }); } } - -export function initPatchSubCasesApi({ - router, - caseService, - userActionService, - logger, -}: RouteDeps) { - router.patch( - { - path: SUB_CASES_PATCH_DEL_URL, - validate: { - body: escapeHatch, - }, - }, - async (context, request, response) => { - try { - const casesClient = context.cases.getCasesClient(); - const subCases = request.body as SubCasesPatchRequest; - - return response.ok({ - body: await update({ - request, - subCases, - casesClient, - client: context.core.savedObjects.client, - caseService, - userActionService, - logger, - }), - }); - } catch (error) { - logger.error(`Failed to patch sub cases in route: ${error}`); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/client/typedoc_interfaces.ts b/x-pack/plugins/cases/server/client/typedoc_interfaces.ts new file mode 100644 index 0000000000000..bf444ee9420ed --- /dev/null +++ b/x-pack/plugins/cases/server/client/typedoc_interfaces.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * This file defines simpler types for typedoc. This helps reduce the type alias expansion for the io-ts types because it + * can be very large. These types are equivalent to the io-ts aliases. + * @module + */ + +/* eslint-disable @typescript-eslint/no-empty-interface */ + +import { + AllCommentsResponse, + CasePostRequest, + CaseResponse, + CasesConfigurePatch, + CasesConfigureRequest, + CasesConfigureResponse, + CasesFindRequest, + CasesFindResponse, + CasesPatchRequest, + CasesResponse, + CaseUserActionsResponse, + CommentsResponse, + SubCaseResponse, + SubCasesFindResponse, + SubCasesResponse, +} from '../../common'; + +/** + * These are simply to make typedoc not attempt to expand the type aliases. If it attempts to expand them + * the docs are huge. + */ + +export interface ICasePostRequest extends CasePostRequest {} +export interface ICasesFindRequest extends CasesFindRequest {} +export interface ICasesPatchRequest extends CasesPatchRequest {} +export interface ICaseResponse extends CaseResponse {} +export interface ICasesResponse extends CasesResponse {} +export interface ICasesFindResponse extends CasesFindResponse {} + +export interface ICasesConfigureResponse extends CasesConfigureResponse {} +export interface ICasesConfigureRequest extends CasesConfigureRequest {} +export interface ICasesConfigurePatch extends CasesConfigurePatch {} + +export interface ICommentsResponse extends CommentsResponse {} +export interface IAllCommentsResponse extends AllCommentsResponse {} + +export interface ISubCasesFindResponse extends SubCasesFindResponse {} +export interface ISubCaseResponse extends SubCaseResponse {} +export interface ISubCasesResponse extends SubCasesResponse {} + +export interface ICaseUserActionsResponse extends CaseUserActionsResponse {} diff --git a/x-pack/plugins/cases/server/client/types.ts b/x-pack/plugins/cases/server/client/types.ts index 3311b7ac6f921..f6b229b94800d 100644 --- a/x-pack/plugins/cases/server/client/types.ts +++ b/x-pack/plugins/cases/server/client/types.ts @@ -5,111 +5,34 @@ * 2.0. */ +import type { PublicMethodsOf } from '@kbn/utility-types'; import { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'kibana/server'; -import { ActionsClient } from '../../../actions/server'; -import { - CasePostRequest, - CaseResponse, - CasesPatchRequest, - CasesResponse, - CaseStatuses, - CommentRequest, - ConnectorMappingsAttributes, - GetFieldsResponse, - CaseUserActionsResponse, - User, -} from '../../common'; -import { AlertInfo } from '../common'; +import { User } from '../../common/api'; +import { Authorization } from '../authorization/authorization'; import { - CaseConfigureServiceSetup, - CaseServiceSetup, - CaseUserActionServiceSetup, AlertServiceContract, + CaseConfigureService, + CasesService, + CaseUserActionService, + ConnectorMappingsService, + AttachmentService, } from '../services'; -import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; -import { CasesClientGetAlertsResponse } from './alerts/types'; - -export interface CasesClientGet { - id: string; - includeComments?: boolean; - includeSubCaseComments?: boolean; -} - -export interface CasesClientPush { - actionsClient: ActionsClient; - caseId: string; - connectorId: string; -} - -export interface CasesClientAddComment { - caseId: string; - comment: CommentRequest; -} - -export interface CasesClientUpdateAlertsStatus { - alerts: UpdateAlertRequest[]; -} - -export interface CasesClientGetAlerts { - alertsInfo: AlertInfo[]; -} - -export interface CasesClientGetUserActions { - caseId: string; - subCaseId?: string; -} - -export interface MappingsClient { - actionsClient: ActionsClient; - connectorId: string; - connectorType: string; -} - -export interface CasesClientFactoryArguments { - scopedClusterClient: ElasticsearchClient; - caseConfigureService: CaseConfigureServiceSetup; - caseService: CaseServiceSetup; - connectorMappingsService: ConnectorMappingsServiceSetup; - user: User; - savedObjectsClient: SavedObjectsClientContract; - userActionService: CaseUserActionServiceSetup; - alertsService: AlertServiceContract; - logger: Logger; -} - -export interface ConfigureFields { - actionsClient: ActionsClient; - connectorId: string; - connectorType: string; -} - -/** - * Defines the fields necessary to update an alert's status. - */ -export interface UpdateAlertRequest { - id: string; - index: string; - status: CaseStatuses; -} +import { ActionsClient } from '../../../actions/server'; /** - * This represents the interface that other plugins can access. + * Parameters for initializing a cases client */ -export interface CasesClient { - addComment(args: CasesClientAddComment): Promise; - create(theCase: CasePostRequest): Promise; - get(args: CasesClientGet): Promise; - getAlerts(args: CasesClientGetAlerts): Promise; - getFields(args: ConfigureFields): Promise; - getMappings(args: MappingsClient): Promise; - getUserActions(args: CasesClientGetUserActions): Promise; - push(args: CasesClientPush): Promise; - update(args: CasesPatchRequest): Promise; - updateAlertsStatus(args: CasesClientUpdateAlertsStatus): Promise; -} - -export interface MappingsClient { - actionsClient: ActionsClient; - connectorId: string; - connectorType: string; +export interface CasesClientArgs { + readonly scopedClusterClient: ElasticsearchClient; + readonly caseConfigureService: CaseConfigureService; + readonly caseService: CasesService; + readonly connectorMappingsService: ConnectorMappingsService; + readonly user: User; + readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + readonly userActionService: CaseUserActionService; + readonly alertsService: AlertServiceContract; + readonly attachmentService: AttachmentService; + readonly logger: Logger; + readonly authorization: PublicMethodsOf; + readonly actionsClient: PublicMethodsOf; } diff --git a/x-pack/plugins/cases/server/client/user_actions/client.ts b/x-pack/plugins/cases/server/client/user_actions/client.ts new file mode 100644 index 0000000000000..1e2fe8d4f4fca --- /dev/null +++ b/x-pack/plugins/cases/server/client/user_actions/client.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { ICaseUserActionsResponse } from '../typedoc_interfaces'; +import { CasesClientArgs } from '../types'; +import { get } from './get'; + +/** + * Parameters for retrieving user actions for a particular case + */ +export interface UserActionGet { + /** + * The ID of the case + */ + caseId: string; + /** + * If specified then a sub case will be used for finding all the user actions + */ + subCaseId?: string; +} + +/** + * API for interacting the actions performed by a user when interacting with the cases entities. + */ +export interface UserActionsSubClient { + /** + * Retrieves all user actions for a particular case. + */ + getAll(clientArgs: UserActionGet): Promise; +} + +/** + * Creates an API object for interacting with the user action entities + * + * @ignore + */ +export const createUserActionsSubClient = (clientArgs: CasesClientArgs): UserActionsSubClient => { + const attachmentSubClient: UserActionsSubClient = { + getAll: (params: UserActionGet) => get(params, clientArgs), + }; + + return Object.freeze(attachmentSubClient); +}; diff --git a/x-pack/plugins/cases/server/client/user_actions/get.ts b/x-pack/plugins/cases/server/client/user_actions/get.ts index 79b8ef25ab0f6..a0dddc79ef4b4 100644 --- a/x-pack/plugins/cases/server/client/user_actions/get.ts +++ b/x-pack/plugins/cases/server/client/user_actions/get.ts @@ -5,49 +5,63 @@ * 2.0. */ -import { SavedObjectsClientContract } from 'kibana/server'; import { + SUB_CASE_SAVED_OBJECT, CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, -} from '../../saved_object_types'; -import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common'; -import { CaseUserActionServiceSetup } from '../../services'; +} from '../../../common/constants'; +import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api'; +import { createCaseError } from '../../common/error'; +import { checkEnabledCaseConnectorOrThrow } from '../../common'; +import { CasesClientArgs } from '..'; +import { Operations } from '../../authorization'; +import { UserActionGet } from './client'; + +export const get = async ( + { caseId, subCaseId }: UserActionGet, + clientArgs: CasesClientArgs +): Promise => { + const { unsecuredSavedObjectsClient, userActionService, logger, authorization } = clientArgs; + + try { + checkEnabledCaseConnectorOrThrow(subCaseId); -interface GetParams { - savedObjectsClient: SavedObjectsClientContract; - userActionService: CaseUserActionServiceSetup; - caseId: string; - subCaseId?: string; -} + const userActions = await userActionService.getAll({ + unsecuredSavedObjectsClient, + caseId, + subCaseId, + }); -export const get = async ({ - savedObjectsClient, - userActionService, - caseId, - subCaseId, -}: GetParams): Promise => { - const userActions = await userActionService.getUserActions({ - client: savedObjectsClient, - caseId, - subCaseId, - }); + await authorization.ensureAuthorized({ + entities: userActions.saved_objects.map((userAction) => ({ + owner: userAction.attributes.owner, + id: userAction.id, + })), + operation: Operations.getUserActions, + }); - return CaseUserActionsResponseRt.encode( - userActions.saved_objects.reduce((acc, ua) => { - if (subCaseId == null && ua.references.some((uar) => uar.type === SUB_CASE_SAVED_OBJECT)) { - return acc; - } - return [ - ...acc, - { - ...ua.attributes, - action_id: ua.id, - case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', - comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, - sub_case_id: ua.references.find((r) => r.type === SUB_CASE_SAVED_OBJECT)?.id ?? '', - }, - ]; - }, []) - ); + return CaseUserActionsResponseRt.encode( + userActions.saved_objects.reduce((acc, ua) => { + if (subCaseId == null && ua.references.some((uar) => uar.type === SUB_CASE_SAVED_OBJECT)) { + return acc; + } + return [ + ...acc, + { + ...ua.attributes, + action_id: ua.id, + case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', + comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, + sub_case_id: ua.references.find((r) => r.type === SUB_CASE_SAVED_OBJECT)?.id ?? '', + }, + ]; + }, []) + ); + } catch (error) { + throw createCaseError({ + message: `Failed to retrieve user actions case id: ${caseId} sub case id: ${subCaseId}: ${error}`, + error, + logger, + }); + } }; diff --git a/x-pack/plugins/cases/server/client/utils.test.ts b/x-pack/plugins/cases/server/client/utils.test.ts new file mode 100644 index 0000000000000..c8ed1f4f0efa6 --- /dev/null +++ b/x-pack/plugins/cases/server/client/utils.test.ts @@ -0,0 +1,329 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { SavedObjectsFindResponse } from 'kibana/server'; +import { + CaseConnector, + CaseType, + ConnectorTypes, + ESCaseConnector, + ESCasesConfigureAttributes, +} from '../../common/api'; +import { mockCaseConfigure } from '../routes/api/__fixtures__'; +import { newCase } from '../routes/api/__mocks__/request_responses'; +import { + transformCaseConnectorToEsConnector, + transformESConnectorToCaseConnector, + transformNewCase, +} from '../common'; +import { getConnectorFromConfiguration, sortToSnake } from './utils'; + +describe('utils', () => { + const caseConnector: CaseConnector = { + id: '123', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }; + + const esCaseConnector: ESCaseConnector = { + id: '123', + name: 'Jira', + type: ConnectorTypes.jira, + fields: [ + { key: 'issueType', value: 'Task' }, + { key: 'priority', value: 'High' }, + { key: 'parent', value: null }, + ], + }; + + const caseConfigure: SavedObjectsFindResponse = { + saved_objects: [{ ...mockCaseConfigure[0], score: 0 }], + total: 1, + per_page: 20, + page: 1, + }; + + describe('transformCaseConnectorToEsConnector', () => { + it('transform correctly', () => { + expect(transformCaseConnectorToEsConnector(caseConnector)).toEqual(esCaseConnector); + }); + + it('transform correctly with null attributes', () => { + // @ts-ignore this is case the connector does not exist for old cases object or configurations + expect(transformCaseConnectorToEsConnector(null)).toEqual({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: [], + }); + }); + }); + + describe('transformESConnectorToCaseConnector', () => { + it('transform correctly', () => { + expect(transformESConnectorToCaseConnector(esCaseConnector)).toEqual(caseConnector); + }); + + it('transform correctly with null attributes', () => { + // @ts-ignore this is case the connector does not exist for old cases object or configurations + expect(transformESConnectorToCaseConnector(null)).toEqual({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }); + }); + }); + + describe('getConnectorFromConfiguration', () => { + it('transform correctly', () => { + expect(getConnectorFromConfiguration(caseConfigure)).toEqual({ + id: '789', + name: 'My connector 3', + type: ConnectorTypes.jira, + fields: null, + }); + }); + + it('transform correctly with no connector', () => { + const caseConfigureNoConnector: SavedObjectsFindResponse = { + ...caseConfigure, + saved_objects: [ + { + ...mockCaseConfigure[0], + // @ts-ignore this is case the connector does not exist for old cases object or configurations + attributes: { ...mockCaseConfigure[0].attributes, connector: null }, + score: 0, + }, + ], + }; + + expect(getConnectorFromConfiguration(caseConfigureNoConnector)).toEqual({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }); + }); + }); + + describe('sortToSnake', () => { + it('it transforms status correctly', () => { + expect(sortToSnake('status')).toBe('status'); + }); + + it('it transforms createdAt correctly', () => { + expect(sortToSnake('createdAt')).toBe('created_at'); + }); + + it('it transforms created_at correctly', () => { + expect(sortToSnake('created_at')).toBe('created_at'); + }); + + it('it transforms closedAt correctly', () => { + expect(sortToSnake('closedAt')).toBe('closed_at'); + }); + + it('it transforms closed_at correctly', () => { + expect(sortToSnake('closed_at')).toBe('closed_at'); + }); + + it('it transforms default correctly', () => { + expect(sortToSnake('not-exist')).toBe('created_at'); + }); + }); + + describe('transformNewCase', () => { + const connector: ESCaseConnector = { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: [ + { key: 'issueType', value: 'Task' }, + { key: 'priority', value: 'High' }, + { key: 'parent', value: null }, + ], + }; + it('transform correctly', () => { + const myCase = { + newCase: { ...newCase, type: CaseType.individual }, + connector, + createdDate: '2020-04-09T09:43:51.778Z', + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }; + + const res = transformNewCase(myCase); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "description": "A description", + "external_service": null, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); + }); + + it('transform correctly without optional fields', () => { + const myCase = { + newCase: { ...newCase, type: CaseType.individual }, + connector, + createdDate: '2020-04-09T09:43:51.778Z', + }; + + const res = transformNewCase(myCase); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "description": "A description", + "external_service": null, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); + }); + + it('transform correctly with optional fields as null', () => { + const myCase = { + newCase: { ...newCase, type: CaseType.individual }, + connector, + createdDate: '2020-04-09T09:43:51.778Z', + email: null, + full_name: null, + username: null, + }; + + const res = transformNewCase(myCase); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "A description", + "external_service": null, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts new file mode 100644 index 0000000000000..7ceb9cec60c39 --- /dev/null +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -0,0 +1,479 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { badRequest } from '@hapi/boom'; +import { get, isPlainObject } from 'lodash'; +import deepEqual from 'fast-deep-equal'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { SavedObjectsFindResponse } from 'kibana/server'; +import { nodeBuilder, KueryNode } from '../../../../../src/plugins/data/common'; +import { esKuery } from '../../../../../src/plugins/data/server'; +import { + CaseConnector, + ESCasesConfigureAttributes, + ConnectorTypes, + CaseStatuses, + CaseType, + CommentRequest, + throwErrors, + excess, + ContextTypeUserRt, + AlertCommentRequestRt, + OWNER_FIELD, +} from '../../common/api'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../common/constants'; +import { combineFilterWithAuthorizationFilter } from '../authorization/utils'; +import { + getIDsAndIndicesAsArrays, + isCommentRequestTypeAlertOrGenAlert, + isCommentRequestTypeUser, + SavedObjectFindOptionsKueryNode, +} from '../common'; + +export const decodeCommentRequest = (comment: CommentRequest) => { + if (isCommentRequestTypeUser(comment)) { + pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isCommentRequestTypeAlertOrGenAlert(comment)) { + pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); + const { ids, indices } = getIDsAndIndicesAsArrays(comment); + + /** + * The alertId and index field must either be both of type string or they must both be string[] and be the same length. + * Having a one-to-one relationship between the id and index of an alert avoids accidentally updating or + * retrieving the wrong alert. Elasticsearch only guarantees that the _id (the field we use for alertId) to be + * unique within a single index. So if we attempt to update or get a specific alert across multiple indices we could + * update or receive the wrong one. + * + * Consider the situation where we have a alert1 with _id = '100' in index 'my-index-awesome' and also in index + * 'my-index-hi'. + * If we attempt to update the status of alert1 using an index pattern like `my-index-*` or even providing multiple + * indices, there's a chance we'll accidentally update too many alerts. + * + * This check doesn't enforce that the API request has the correct alert ID to index relationship it just guards + * against accidentally making a request like: + * { + * alertId: [1,2,3], + * index: awesome, + * } + * + * Instead this requires the requestor to provide: + * { + * alertId: [1,2,3], + * index: [awesome, awesome, awesome] + * } + * + * Ideally we'd change the format of the comment request to be an array of objects like: + * { + * alerts: [{id: 1, index: awesome}, {id: 2, index: awesome}] + * } + * + * But we'd need to also implement a migration because the saved object document currently stores the id and index + * in separate fields. + */ + if (ids.length !== indices.length) { + throw badRequest( + `Received an alert comment with ids and indices arrays of different lengths ids: ${JSON.stringify( + ids + )} indices: ${JSON.stringify(indices)}` + ); + } + } +}; + +/** + * Return the alert IDs from the comment if it is an alert style comment. Otherwise return an empty array. + */ +export const getAlertIds = (comment: CommentRequest): string[] => { + if (isCommentRequestTypeAlertOrGenAlert(comment)) { + return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + } + return []; +}; + +export const addStatusFilter = ({ + status, + appendFilter, + type = CASE_SAVED_OBJECT, +}: { + status: CaseStatuses; + appendFilter?: KueryNode; + type?: string; +}): KueryNode => { + const filters: KueryNode[] = []; + filters.push(nodeBuilder.is(`${type}.attributes.status`, status)); + + if (appendFilter) { + filters.push(appendFilter); + } + + return filters.length > 1 ? nodeBuilder.and(filters) : filters[0]; +}; + +interface FilterField { + filters?: string | string[]; + field: string; + operator: 'and' | 'or'; + type?: string; +} + +export const buildFilter = ({ + filters, + field, + operator, + type = CASE_SAVED_OBJECT, +}: FilterField): KueryNode | undefined => { + if (filters === undefined) { + return; + } + + const filtersAsArray = Array.isArray(filters) ? filters : [filters]; + + if (filtersAsArray.length === 0) { + return; + } + + return nodeBuilder[operator]( + filtersAsArray.map((filter) => nodeBuilder.is(`${type}.attributes.${field}`, filter)) + ); +}; + +/** + * Combines the authorized filters with the requested owners. + */ +export const combineAuthorizedAndOwnerFilter = ( + owner?: string[] | string, + authorizationFilter?: KueryNode, + savedObjectType?: string +): KueryNode | undefined => { + const ownerFilter = buildFilter({ + filters: owner, + field: OWNER_FIELD, + operator: 'or', + type: savedObjectType, + }); + + return combineFilterWithAuthorizationFilter(ownerFilter, authorizationFilter); +}; + +/** + * Combines Kuery nodes and accepts an array with a mixture of undefined and KueryNodes. This will filter out the undefined + * filters and return a KueryNode with the filters and'd together. + */ +export function combineFilters(nodes: Array): KueryNode | undefined { + const filters = nodes.filter((node): node is KueryNode => node !== undefined); + if (filters.length <= 0) { + return; + } + return nodeBuilder.and(filters); +} + +/** + * Creates a KueryNode from a string expression. Returns undefined if the expression is undefined. + */ +export function stringToKueryNode(expression?: string): KueryNode | undefined { + if (!expression) { + return; + } + + return esKuery.fromKueryExpression(expression); +} + +/** + * Constructs the filters used for finding cases and sub cases. + * There are a few scenarios that this function tries to handle when constructing the filters used for finding cases + * and sub cases. + * + * Scenario 1: + * Type == Individual + * If the API request specifies that it wants only individual cases (aka not collections) then we need to add that + * specific filter when call the saved objects find api. This will filter out any collection cases. + * + * Scenario 2: + * Type == collection + * If the API request specifies that it only wants collection cases (cases that have sub cases) then we need to add + * the filter for collections AND we need to ignore any status filter for the case find call. This is because a + * collection's status is no longer relevant when it has sub cases. The user cannot change the status for a collection + * only for its sub cases. The status filter will be applied to the find request when looking for sub cases. + * + * Scenario 3: + * No Type is specified + * If the API request does not want to filter on type but instead get both collections and regular individual cases then + * we need to find all cases that match the other filter criteria and sub cases. To do this we construct the following query: + * + * ((status == some_status and type === individual) or type == collection) and (tags == blah) and (reporter == yo) + * This forces us to honor the status request for individual cases but gets us ALL collection cases that match the other + * filter criteria. When we search for sub cases we will use that status filter in that find call as well. + */ +export const constructQueryOptions = ({ + tags, + reporters, + status, + sortByField, + caseType, + owner, + authorizationFilter, +}: { + tags?: string | string[]; + reporters?: string | string[]; + status?: CaseStatuses; + sortByField?: string; + caseType?: CaseType; + owner?: string | string[]; + authorizationFilter?: KueryNode; +}): { case: SavedObjectFindOptionsKueryNode; subCase?: SavedObjectFindOptionsKueryNode } => { + const kueryNodeExists = (filter: KueryNode | null | undefined): filter is KueryNode => + filter != null; + + const tagsFilter = buildFilter({ filters: tags ?? [], field: 'tags', operator: 'or' }); + const reportersFilter = buildFilter({ + filters: reporters ?? [], + field: 'created_by.username', + operator: 'or', + }); + const sortField = sortToSnake(sortByField); + const ownerFilter = buildFilter({ filters: owner ?? [], field: OWNER_FIELD, operator: 'or' }); + + switch (caseType) { + case CaseType.individual: { + // The cases filter will result in this structure "status === oh and (type === individual) and (tags === blah) and (reporter === yo)" + // The subCase filter will be undefined because we don't need to find sub cases if type === individual + + // We do not want to support multiple type's being used, so force it to be a single filter value + const typeFilter = nodeBuilder.is( + `${CASE_SAVED_OBJECT}.attributes.type`, + CaseType.individual + ); + + const filters: KueryNode[] = [typeFilter, tagsFilter, reportersFilter, ownerFilter].filter( + kueryNodeExists + ); + + const caseFilters = + status != null + ? addStatusFilter({ + status, + appendFilter: filters.length > 1 ? nodeBuilder.and(filters) : filters[0], + }) + : undefined; + + return { + case: { + filter: combineFilterWithAuthorizationFilter(caseFilters, authorizationFilter), + sortField, + }, + }; + } + case CaseType.collection: { + // The cases filter will result in this structure "(type == parent) and (tags == blah) and (reporter == yo)" + // The sub case filter will use the query.status if it exists + const typeFilter = nodeBuilder.is( + `${CASE_SAVED_OBJECT}.attributes.type`, + CaseType.collection + ); + + const filters: KueryNode[] = [typeFilter, tagsFilter, reportersFilter, ownerFilter].filter( + kueryNodeExists + ); + const caseFilters = filters.length > 1 ? nodeBuilder.and(filters) : filters[0]; + const subCaseFilters = + status != null ? addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }) : undefined; + + return { + case: { + filter: combineFilterWithAuthorizationFilter(caseFilters, authorizationFilter), + sortField, + }, + subCase: { + filter: combineFilterWithAuthorizationFilter(subCaseFilters, authorizationFilter), + sortField, + }, + }; + } + default: { + /** + * In this scenario no type filter was sent, so we want to honor the status filter if one exists. + * To construct the filter and honor the status portion we need to find all individual cases that + * have that particular status. We also need to find cases that have sub cases but we want to ignore the + * case collection's status because it is not relevant. We only care about the status of the sub cases if the + * case is a collection. + * + * The cases filter will result in this structure "((status == open and type === individual) or type == parent) and (tags == blah) and (reporter == yo)" + * The sub case filter will use the query.status if it exists + */ + const typeIndividual = nodeBuilder.is( + `${CASE_SAVED_OBJECT}.attributes.type`, + CaseType.individual + ); + const typeParent = nodeBuilder.is( + `${CASE_SAVED_OBJECT}.attributes.type`, + CaseType.collection + ); + + const statusFilter = + status != null + ? nodeBuilder.and([addStatusFilter({ status }), typeIndividual]) + : typeIndividual; + const statusAndType = nodeBuilder.or([statusFilter, typeParent]); + + const filters: KueryNode[] = [statusAndType, tagsFilter, reportersFilter, ownerFilter].filter( + kueryNodeExists + ); + + const caseFilters = filters.length > 1 ? nodeBuilder.and(filters) : filters[0]; + const subCaseFilters = + status != null ? addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }) : undefined; + + return { + case: { + filter: combineFilterWithAuthorizationFilter(caseFilters, authorizationFilter), + sortField, + }, + subCase: { + filter: combineFilterWithAuthorizationFilter(subCaseFilters, authorizationFilter), + sortField, + }, + }; + } + } +}; + +interface CompareArrays { + addedItems: string[]; + deletedItems: string[]; +} +export const compareArrays = ({ + originalValue, + updatedValue, +}: { + originalValue: string[]; + updatedValue: string[]; +}): CompareArrays => { + const result: CompareArrays = { + addedItems: [], + deletedItems: [], + }; + originalValue.forEach((origVal) => { + if (!updatedValue.includes(origVal)) { + result.deletedItems = [...result.deletedItems, origVal]; + } + }); + updatedValue.forEach((updatedVal) => { + if (!originalValue.includes(updatedVal)) { + result.addedItems = [...result.addedItems, updatedVal]; + } + }); + + return result; +}; + +export const isTwoArraysDifference = ( + originalValue: unknown, + updatedValue: unknown +): CompareArrays | null => { + if ( + originalValue != null && + updatedValue != null && + Array.isArray(updatedValue) && + Array.isArray(originalValue) + ) { + const compObj = compareArrays({ originalValue, updatedValue }); + if (compObj.addedItems.length > 0 || compObj.deletedItems.length > 0) { + return compObj; + } + } + return null; +}; + +interface CaseWithIDVersion { + id: string; + version: string; + [key: string]: unknown; +} + +export const getCaseToUpdate = ( + currentCase: unknown, + queryCase: CaseWithIDVersion +): CaseWithIDVersion => + Object.entries(queryCase).reduce( + (acc, [key, value]) => { + const currentValue = get(currentCase, key); + if (Array.isArray(currentValue) && Array.isArray(value)) { + if (isTwoArraysDifference(value, currentValue)) { + return { + ...acc, + [key]: value, + }; + } + return acc; + } else if (isPlainObject(currentValue) && isPlainObject(value)) { + if (!deepEqual(currentValue, value)) { + return { + ...acc, + [key]: value, + }; + } + + return acc; + } else if (currentValue != null && value !== currentValue) { + return { + ...acc, + [key]: value, + }; + } + return acc; + }, + { id: queryCase.id, version: queryCase.version } + ); + +export const getNoneCaseConnector = () => ({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, +}); + +export const getConnectorFromConfiguration = ( + caseConfigure: SavedObjectsFindResponse +): CaseConnector => { + let caseConnector = getNoneCaseConnector(); + if ( + caseConfigure.saved_objects.length > 0 && + caseConfigure.saved_objects[0].attributes.connector + ) { + caseConnector = { + id: caseConfigure.saved_objects[0].attributes.connector.id, + name: caseConfigure.saved_objects[0].attributes.connector.name, + type: caseConfigure.saved_objects[0].attributes.connector.type, + fields: null, + }; + } + return caseConnector; +}; + +enum SortFieldCase { + closedAt = 'closed_at', + createdAt = 'created_at', + status = 'status', +} + +export const sortToSnake = (sortField: string | undefined): SortFieldCase => { + switch (sortField) { + case 'status': + return SortFieldCase.status; + case 'createdAt': + case 'created_at': + return SortFieldCase.createdAt; + case 'closedAt': + case 'closed_at': + return SortFieldCase.closedAt; + default: + return SortFieldCase.createdAt; + } +}; diff --git a/x-pack/plugins/cases/server/common/error.ts b/x-pack/plugins/cases/server/common/error.ts index 95b05fd612e60..1b53eb9fdb218 100644 --- a/x-pack/plugins/cases/server/common/error.ts +++ b/x-pack/plugins/cases/server/common/error.ts @@ -28,7 +28,7 @@ class CaseError extends Error { * and data from that. */ public boomify(): Boom { - const message = this.message ?? this.wrappedError?.message; + const message = this.wrappedError?.message ?? this.message; let statusCode = 500; let data: unknown | undefined; diff --git a/x-pack/plugins/cases/server/common/index.ts b/x-pack/plugins/cases/server/common/index.ts index b07ed5d4ae2d6..324c7e7ffd1a8 100644 --- a/x-pack/plugins/cases/server/common/index.ts +++ b/x-pack/plugins/cases/server/common/index.ts @@ -8,3 +8,4 @@ export * from './models'; export * from './utils'; export * from './types'; +export * from './error'; diff --git a/x-pack/plugins/cases/server/common/models/commentable_case.ts b/x-pack/plugins/cases/server/common/models/commentable_case.ts index 3daccf87bdc19..2d1e1e18b5098 100644 --- a/x-pack/plugins/cases/server/common/models/commentable_case.ts +++ b/x-pack/plugins/cases/server/common/models/commentable_case.ts @@ -27,15 +27,15 @@ import { ESCaseAttributes, SubCaseAttributes, User, -} from '../../../common'; -import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; +} from '../../../common/api'; import { + transformESConnectorToCaseConnector, flattenCommentSavedObjects, flattenSubCaseSavedObject, transformNewComment, -} from '../../routes/api/utils'; -import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; -import { CaseServiceSetup } from '../../services'; +} from '..'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../common/constants'; +import { AttachmentService, CasesService } from '../../services'; import { createCaseError } from '../error'; import { countAlertsForID } from '../index'; @@ -52,8 +52,9 @@ interface NewCommentResp { interface CommentableCaseParams { collection: SavedObject; subCase?: SavedObject; - soClient: SavedObjectsClientContract; - service: CaseServiceSetup; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + caseService: CasesService; + attachmentService: AttachmentService; logger: Logger; } @@ -64,15 +65,24 @@ interface CommentableCaseParams { export class CommentableCase { private readonly collection: SavedObject; private readonly subCase?: SavedObject; - private readonly soClient: SavedObjectsClientContract; - private readonly service: CaseServiceSetup; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + private readonly caseService: CasesService; + private readonly attachmentService: AttachmentService; private readonly logger: Logger; - constructor({ collection, subCase, soClient, service, logger }: CommentableCaseParams) { + constructor({ + collection, + subCase, + unsecuredSavedObjectsClient, + caseService, + attachmentService, + logger, + }: CommentableCaseParams) { this.collection = collection; this.subCase = subCase; - this.soClient = soClient; - this.service = service; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + this.caseService = caseService; + this.attachmentService = attachmentService; this.logger = logger; } @@ -109,6 +119,10 @@ export class CommentableCase { return this.subCase?.id; } + private get owner(): string { + return this.collection.attributes.owner; + } + private buildRefsToCase(): SavedObjectReference[] { const subCaseSOType = SUB_CASE_SAVED_OBJECT; const caseSOType = CASE_SAVED_OBJECT; @@ -129,8 +143,8 @@ export class CommentableCase { let updatedSubCaseAttributes: SavedObject | undefined; if (this.subCase) { - const updatedSubCase = await this.service.patchSubCase({ - client: this.soClient, + const updatedSubCase = await this.caseService.patchSubCase({ + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, subCaseId: this.subCase.id, updatedAttributes: { updated_at: date, @@ -151,8 +165,8 @@ export class CommentableCase { }; } - const updatedCase = await this.service.patchCase({ - client: this.soClient, + const updatedCase = await this.caseService.patchCase({ + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, caseId: this.collection.id, updatedAttributes: { updated_at: date, @@ -172,8 +186,9 @@ export class CommentableCase { version: updatedCase.version ?? this.collection.version, }, subCase: updatedSubCaseAttributes, - soClient: this.soClient, - service: this.service, + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + caseService: this.caseService, + attachmentService: this.attachmentService, logger: this.logger, }); } catch (error) { @@ -201,9 +216,9 @@ export class CommentableCase { const { id, version, ...queryRestAttributes } = updateRequest; const [comment, commentableCase] = await Promise.all([ - this.service.patchComment({ - client: this.soClient, - commentId: id, + this.attachmentService.update({ + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, + attachmentId: id, updatedAttributes: { ...queryRestAttributes, updated_at: updatedAt, @@ -233,10 +248,12 @@ export class CommentableCase { createdDate, user, commentReq, + id, }: { createdDate: string; user: User; commentReq: CommentRequest; + id: string; }): Promise { try { if (commentReq.type === CommentType.alert) { @@ -249,9 +266,13 @@ export class CommentableCase { } } + if (commentReq.owner !== this.owner) { + throw Boom.badRequest('The owner field of the comment must match the case'); + } + const [comment, commentableCase] = await Promise.all([ - this.service.postNewComment({ - client: this.soClient, + this.attachmentService.create({ + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, attributes: transformNewComment({ associationType: this.subCase ? AssociationType.subCase : AssociationType.case, createdDate, @@ -259,6 +280,7 @@ export class CommentableCase { ...user, }), references: this.buildRefsToCase(), + id, }), this.update({ date: createdDate, user }), ]); @@ -287,8 +309,8 @@ export class CommentableCase { public async encode(): Promise { try { - const collectionCommentStats = await this.service.getAllCaseComments({ - client: this.soClient, + const collectionCommentStats = await this.caseService.getAllCaseComments({ + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, id: this.collection.id, options: { fields: [], @@ -297,8 +319,8 @@ export class CommentableCase { }, }); - const collectionComments = await this.service.getAllCaseComments({ - client: this.soClient, + const collectionComments = await this.caseService.getAllCaseComments({ + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, id: this.collection.id, options: { fields: [], @@ -317,8 +339,8 @@ export class CommentableCase { }; if (this.subCase) { - const subCaseComments = await this.service.getAllSubCaseComments({ - client: this.soClient, + const subCaseComments = await this.caseService.getAllSubCaseComments({ + unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, id: this.subCase.id, }); const totalAlerts = diff --git a/x-pack/plugins/cases/server/common/types.ts b/x-pack/plugins/cases/server/common/types.ts index b58d8ec0e849e..b99612f1b1cfe 100644 --- a/x-pack/plugins/cases/server/common/types.ts +++ b/x-pack/plugins/cases/server/common/types.ts @@ -5,6 +5,9 @@ * 2.0. */ +import { KueryNode } from '../../../../../src/plugins/data/server'; +import { SavedObjectFindOptions } from '../../common/api'; + /** * This structure holds the alert ID and index from an alert comment */ @@ -12,3 +15,7 @@ export interface AlertInfo { id: string; index: string; } + +export type SavedObjectFindOptionsKueryNode = Omit & { + filter?: KueryNode; +}; diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index df16fe4f0a67d..322e45094eda4 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -6,9 +6,30 @@ */ import { SavedObjectsFindResponse } from 'kibana/server'; -import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common'; -import { transformNewComment } from '../routes/api/utils'; -import { combineFilters, countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; +import { SECURITY_SOLUTION_OWNER } from '../../common'; +import { + AssociationType, + CaseResponse, + CommentAttributes, + CommentRequest, + CommentType, +} from '../../common/api'; +import { + mockCaseComments, + mockCases, + mockCaseNoConnectorId, +} from '../routes/api/__fixtures__/mock_saved_objects'; +import { + flattenCaseSavedObject, + transformNewComment, + countAlerts, + countAlertsForID, + groupTotalAlertsByID, + transformCases, + transformComments, + flattenCommentSavedObjects, + flattenCommentSavedObject, +} from './utils'; interface CommentReference { ids: string[]; @@ -47,33 +68,613 @@ function createCommentFindResponse( } describe('common utils', () => { - describe('combineFilters', () => { - it("creates a filter string with two values and'd together", () => { - expect(combineFilters(['a', 'b'], 'AND')).toBe('(a AND b)'); + describe('transformCases', () => { + it('transforms correctly', () => { + const casesMap = new Map( + mockCases.map((obj) => { + return [obj.id, flattenCaseSavedObject({ savedObject: obj, totalComment: 2 })]; + }) + ); + const res = transformCases({ + casesMap, + countOpenCases: 2, + countInProgressCases: 2, + countClosedCases: 2, + page: 1, + perPage: 10, + total: casesMap.size, + }); + expect(res).toMatchInlineSnapshot(` + Object { + "cases": Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T22:32:00.900Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie destroying data!", + "external_service": null, + "id": "mock-id-2", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "Data Destruction", + ], + "title": "Damaging Data Destruction Detected", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:00.900Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzQsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + Object { + "closed_at": "2019-11-25T22:32:17.947Z", + "closed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + ], + "count_closed_cases": 2, + "count_in_progress_cases": 2, + "count_open_cases": 2, + "page": 1, + "per_page": 10, + "total": 4, + } + `); }); + }); + + describe('flattenCaseSavedObject', () => { + it('flattens correctly', () => { + const myCase = { ...mockCases[2] }; + const res = flattenCaseSavedObject({ + savedObject: myCase, + totalComment: 2, + }); - it('creates a filter string with three values or together', () => { - expect(combineFilters(['a', 'b', 'c'], 'OR')).toBe('(a OR b OR c)'); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + } + `); }); - it('ignores empty strings', () => { - expect(combineFilters(['', 'a', '', 'b'], 'AND')).toBe('(a AND b)'); + it('flattens correctly without version', () => { + const myCase = { ...mockCases[2] }; + myCase.version = undefined; + const res = flattenCaseSavedObject({ + savedObject: myCase, + totalComment: 2, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "0", + } + `); }); - it('returns an empty string if all filters are empty strings', () => { - expect(combineFilters(['', ''], 'OR')).toBe(''); + it('flattens correctly with comments', () => { + const myCase = { ...mockCases[2] }; + const comments = [{ ...mockCaseComments[0] }]; + const res = flattenCaseSavedObject({ + savedObject: myCase, + comments, + totalComment: 2, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [ + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:55:00.177Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "id": "mock-comment-1", + "owner": "securitySolution", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": "2019-11-25T21:55:00.177Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzEsMV0=", + }, + ], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + } + `); }); - it('returns an empty string if the filters are undefined', () => { - expect(combineFilters(undefined, 'OR')).toBe(''); + it('inserts missing connector', () => { + const extraCaseData = { + totalComment: 2, + }; + + const res = flattenCaseSavedObject({ + // @ts-ignore this is to update old case saved objects to include connector + savedObject: mockCaseNoConnectorId, + ...extraCaseData, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCaseIds": undefined, + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + } + `); }); + }); - it('returns a value without parenthesis when only a single filter is provided', () => { - expect(combineFilters(['a'], 'OR')).toBe('a'); + describe('transformComments', () => { + it('transforms correctly', () => { + const comments = { + saved_objects: mockCaseComments.map((obj) => ({ ...obj, score: 1 })), + total: mockCaseComments.length, + per_page: 10, + page: 1, + }; + + const res = transformComments(comments); + expect(res).toEqual({ + page: 1, + per_page: 10, + total: mockCaseComments.length, + comments: flattenCommentSavedObjects(comments.saved_objects), + }); + }); + }); + + describe('flattenCommentSavedObjects', () => { + it('flattens correctly', () => { + const comments = [{ ...mockCaseComments[0] }, { ...mockCaseComments[1] }]; + const res = flattenCommentSavedObjects(comments); + expect(res).toEqual([ + flattenCommentSavedObject(comments[0]), + flattenCommentSavedObject(comments[1]), + ]); + }); + }); + + describe('flattenCommentSavedObject', () => { + it('flattens correctly', () => { + const comment = { ...mockCaseComments[0] }; + const res = flattenCommentSavedObject(comment); + expect(res).toEqual({ + id: comment.id, + version: comment.version, + ...comment.attributes, + }); + }); + + it('flattens correctly without version', () => { + const comment = { ...mockCaseComments[0] }; + comment.version = undefined; + const res = flattenCommentSavedObject(comment); + expect(res).toEqual({ + id: comment.id, + version: '0', + ...comment.attributes, + }); + }); + }); + + describe('transformNewComment', () => { + it('transforms correctly', () => { + const comment = { + comment: 'A comment', + type: CommentType.user as const, + createdDate: '2020-04-09T09:43:51.778Z', + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + associationType: AssociationType.case, + owner: SECURITY_SOLUTION_OWNER, + }; + + const res = transformNewComment(comment); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "owner": "securitySolution", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); - it('returns a string without parenthesis when only a single non empty filter is provided', () => { - expect(combineFilters(['', ''], 'AND')).toBe(''); + it('transform correctly without optional fields', () => { + const comment = { + comment: 'A comment', + type: CommentType.user as const, + createdDate: '2020-04-09T09:43:51.778Z', + owner: SECURITY_SOLUTION_OWNER, + associationType: AssociationType.case, + }; + + const res = transformNewComment(comment); + + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "owner": "securitySolution", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); + }); + + it('transform correctly with optional fields as null', () => { + const comment = { + comment: 'A comment', + type: CommentType.user as const, + createdDate: '2020-04-09T09:43:51.778Z', + email: null, + full_name: null, + username: null, + owner: SECURITY_SOLUTION_OWNER, + associationType: AssociationType.case, + }; + + const res = transformNewComment(comment); + + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "owner": "securitySolution", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); }); @@ -82,7 +683,10 @@ describe('common utils', () => { expect( countAlerts( createCommentFindResponse([ - { ids: ['1'], comments: [{ comment: '', type: CommentType.user }] }, + { + ids: ['1'], + comments: [{ comment: '', type: CommentType.user, owner: SECURITY_SOLUTION_OWNER }], + }, ]).saved_objects[0] ) ).toBe(0); @@ -103,6 +707,7 @@ describe('common utils', () => { id: 'rule-id-1', name: 'rule-name-1', }, + owner: SECURITY_SOLUTION_OWNER, }, ], }, @@ -126,6 +731,7 @@ describe('common utils', () => { id: 'rule-id-1', name: 'rule-name-1', }, + owner: SECURITY_SOLUTION_OWNER, }, ], }, @@ -146,6 +752,7 @@ describe('common utils', () => { { alertId: ['a', 'b'], index: '', + owner: SECURITY_SOLUTION_OWNER, type: CommentType.alert, rule: { id: 'rule-id-1', @@ -154,6 +761,7 @@ describe('common utils', () => { }, { comment: '', + owner: SECURITY_SOLUTION_OWNER, type: CommentType.user, }, ], @@ -173,6 +781,7 @@ describe('common utils', () => { ids: ['1'], comments: [ { + owner: SECURITY_SOLUTION_OWNER, alertId: ['a', 'b'], index: '', type: CommentType.alert, @@ -187,6 +796,7 @@ describe('common utils', () => { ids: ['2'], comments: [ { + owner: SECURITY_SOLUTION_OWNER, comment: '', type: CommentType.user, }, @@ -210,6 +820,7 @@ describe('common utils', () => { ids: ['1', '2'], comments: [ { + owner: SECURITY_SOLUTION_OWNER, alertId: ['a', 'b'], index: '', type: CommentType.alert, @@ -241,6 +852,7 @@ describe('common utils', () => { ids: ['1', '2'], comments: [ { + owner: SECURITY_SOLUTION_OWNER, alertId: ['a', 'b'], index: '', type: CommentType.alert, diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index d3bc3850e4210..7f38be2ba806d 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -4,11 +4,37 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import Boom from '@hapi/boom'; -import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; -import { CaseStatuses, CommentAttributes, CommentRequest, CommentType, User } from '../../common'; -import { UpdateAlertRequest } from '../client/types'; -import { getAlertInfoFromComments } from '../routes/api/utils'; +import { SavedObjectsFindResult, SavedObjectsFindResponse, SavedObject } from 'kibana/server'; +import { isEmpty } from 'lodash'; +import { AlertInfo } from '.'; + +import { + AssociationType, + CaseConnector, + CaseResponse, + CasesClientPostRequest, + CasesFindResponse, + CaseStatuses, + CommentAttributes, + CommentRequest, + CommentRequestAlertType, + CommentRequestUserType, + CommentResponse, + CommentsResponse, + CommentType, + ConnectorTypeFields, + ESCaseAttributes, + ESCaseConnector, + ESConnectorFields, + SubCaseAttributes, + SubCaseResponse, + SubCasesFindResponse, + User, +} from '../../common/api'; +import { ENABLE_CASE_CONNECTOR } from '../../common/constants'; +import { UpdateAlertRequest } from '../client/alerts/client'; /** * Default sort field for querying saved objects. @@ -20,6 +46,304 @@ export const defaultSortField = 'created_at'; */ export const nullUser: User = { username: null, full_name: null, email: null }; +export const transformNewCase = ({ + connector, + createdDate, + email, + // eslint-disable-next-line @typescript-eslint/naming-convention + full_name, + newCase, + username, +}: { + connector: ESCaseConnector; + createdDate: string; + email?: string | null; + full_name?: string | null; + newCase: CasesClientPostRequest; + username?: string | null; +}): ESCaseAttributes => ({ + ...newCase, + closed_at: null, + closed_by: null, + connector, + created_at: createdDate, + created_by: { email, full_name, username }, + external_service: null, + status: CaseStatuses.open, + updated_at: null, + updated_by: null, +}); + +export const transformCases = ({ + casesMap, + countOpenCases, + countInProgressCases, + countClosedCases, + page, + perPage, + total, +}: { + casesMap: Map; + countOpenCases: number; + countInProgressCases: number; + countClosedCases: number; + page: number; + perPage: number; + total: number; +}): CasesFindResponse => ({ + page, + per_page: perPage, + total, + cases: Array.from(casesMap.values()), + count_open_cases: countOpenCases, + count_in_progress_cases: countInProgressCases, + count_closed_cases: countClosedCases, +}); + +export const transformSubCases = ({ + subCasesMap, + open, + inProgress, + closed, + page, + perPage, + total, +}: { + subCasesMap: Map; + open: number; + inProgress: number; + closed: number; + page: number; + perPage: number; + total: number; +}): SubCasesFindResponse => ({ + page, + per_page: perPage, + total, + // Squish all the entries in the map together as one array + subCases: Array.from(subCasesMap.values()).flat(), + count_open_cases: open, + count_in_progress_cases: inProgress, + count_closed_cases: closed, +}); + +export const flattenCaseSavedObject = ({ + savedObject, + comments = [], + totalComment = comments.length, + totalAlerts = 0, + subCases, + subCaseIds, +}: { + savedObject: SavedObject; + comments?: Array>; + totalComment?: number; + totalAlerts?: number; + subCases?: SubCaseResponse[]; + subCaseIds?: string[]; +}): CaseResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', + comments: flattenCommentSavedObjects(comments), + totalComment, + totalAlerts, + ...savedObject.attributes, + connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), + subCases, + subCaseIds: !isEmpty(subCaseIds) ? subCaseIds : undefined, +}); + +export const flattenSubCaseSavedObject = ({ + savedObject, + comments = [], + totalComment = comments.length, + totalAlerts = 0, +}: { + savedObject: SavedObject; + comments?: Array>; + totalComment?: number; + totalAlerts?: number; +}): SubCaseResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', + comments: flattenCommentSavedObjects(comments), + totalComment, + totalAlerts, + ...savedObject.attributes, +}); + +export const transformComments = ( + comments: SavedObjectsFindResponse +): CommentsResponse => ({ + page: comments.page, + per_page: comments.per_page, + total: comments.total, + comments: flattenCommentSavedObjects(comments.saved_objects), +}); + +export const flattenCommentSavedObjects = ( + savedObjects: Array> +): CommentResponse[] => + savedObjects.reduce((acc: CommentResponse[], savedObject: SavedObject) => { + return [...acc, flattenCommentSavedObject(savedObject)]; + }, []); + +export const flattenCommentSavedObject = ( + savedObject: SavedObject +): CommentResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', + ...savedObject.attributes, +}); + +export const transformCaseConnectorToEsConnector = (connector: CaseConnector): ESCaseConnector => ({ + id: connector?.id ?? 'none', + name: connector?.name ?? 'none', + type: connector?.type ?? '.none', + fields: + connector?.fields != null + ? Object.entries(connector.fields).reduce( + (acc, [key, value]) => [ + ...acc, + { + key, + value, + }, + ], + [] + ) + : [], +}); + +export const transformESConnectorToCaseConnector = (connector?: ESCaseConnector): CaseConnector => { + const connectorTypeField = { + type: connector?.type ?? '.none', + fields: + connector && connector.fields != null && connector.fields.length > 0 + ? connector.fields.reduce( + (fields, { key, value }) => ({ + ...fields, + [key]: value, + }), + {} + ) + : null, + } as ConnectorTypeFields; + + return { + id: connector?.id ?? 'none', + name: connector?.name ?? 'none', + ...connectorTypeField, + }; +}; + +export const getIDsAndIndicesAsArrays = ( + comment: CommentRequestAlertType +): { ids: string[]; indices: string[] } => { + return { + ids: Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId], + indices: Array.isArray(comment.index) ? comment.index : [comment.index], + }; +}; + +/** + * This functions extracts the ids and indices from an alert comment. It enforces that the alertId and index are either + * both strings or string arrays that are the same length. If they are arrays they represent a 1-to-1 mapping of + * id existing in an index at each position in the array. This is not ideal. Ideally an alert comment request would + * accept an array of objects like this: Array<{id: string; index: string; ruleName: string ruleID: string}> instead. + * + * To reformat the alert comment request requires a migration and a breaking API change. + */ +const getAndValidateAlertInfoFromComment = (comment: CommentRequest): AlertInfo[] => { + if (!isCommentRequestTypeAlertOrGenAlert(comment)) { + return []; + } + + const { ids, indices } = getIDsAndIndicesAsArrays(comment); + + if (ids.length !== indices.length) { + return []; + } + + return ids.map((id, index) => ({ id, index: indices[index] })); +}; + +/** + * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alerts. + */ +export const getAlertInfoFromComments = (comments: CommentRequest[] | undefined): AlertInfo[] => { + if (comments === undefined) { + return []; + } + + return comments.reduce((acc: AlertInfo[], comment) => { + const alertInfo = getAndValidateAlertInfoFromComment(comment); + acc.push(...alertInfo); + return acc; + }, []); +}; + +type NewCommentArgs = CommentRequest & { + associationType: AssociationType; + createdDate: string; + owner: string; + email?: string | null; + full_name?: string | null; + username?: string | null; +}; + +export const transformNewComment = ({ + associationType, + createdDate, + email, + // eslint-disable-next-line @typescript-eslint/naming-convention + full_name, + username, + ...comment +}: NewCommentArgs): CommentAttributes => { + return { + associationType, + ...comment, + created_at: createdDate, + created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; +}; + +/** + * A type narrowing function for user comments. Exporting so integration tests can use it. + */ +export const isCommentRequestTypeUser = ( + context: CommentRequest +): context is CommentRequestUserType => { + return context.type === CommentType.user; +}; + +/** + * A type narrowing function for alert comments. Exporting so integration tests can use it. + */ +export const isCommentRequestTypeAlertOrGenAlert = ( + context: CommentRequest +): context is CommentRequestAlertType => { + return context.type === CommentType.alert || context.type === CommentType.generatedAlert; +}; + +/** + * This is used to test if the posted comment is an generated alert. A generated alert will have one or many alerts. + * An alert is essentially an object with a _id field. This differs from a regular attached alert because the _id is + * passed directly in the request, it won't be in an object. Internally case will strip off the outer object and store + * both a generated and user attached alert in the same structure but this function is useful to determine which + * structure the new alert in the request has. + */ +export const isCommentRequestTypeGenAlert = ( + context: CommentRequest +): context is CommentRequestAlertType => { + return context.type === CommentType.generatedAlert; +}; + /** * Adds the ids and indices to a map of statuses */ @@ -33,28 +357,6 @@ export function createAlertUpdateRequest({ return getAlertInfoFromComments([comment]).map((alert) => ({ ...alert, status })); } -/** - * Combines multiple filter expressions using the specified operator and parenthesis if multiple expressions exist. - * This will ignore empty string filters. If a single valid filter is found it will not wrap in parenthesis. - * - * @param filters an array of filters to combine using the specified operator - * @param operator AND or OR - */ -export const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { - const noEmptyStrings = filters?.filter((value) => value !== ''); - const joinedExp = noEmptyStrings?.join(` ${operator} `); - // if undefined or an empty string - if (!joinedExp) { - return ''; - } else if ((noEmptyStrings?.length ?? 0) > 1) { - // if there were multiple filters, wrap them in () - return `(${joinedExp})`; - } else { - // return a single value not wrapped in () - return joinedExp; - } -}; - /** * Counts the total alert IDs within a single comment. */ @@ -113,3 +415,14 @@ export const countAlertsForID = ({ }): number | undefined => { return groupTotalAlertsByID({ comments }).get(id); }; + +/** + * If subCaseID is defined and the case connector feature is disabled this throws an error. + */ +export function checkEnabledCaseConnectorOrThrow(subCaseID: string | undefined) { + if (!ENABLE_CASE_CONNECTOR && subCaseID !== undefined) { + throw Boom.badRequest( + 'The sub case parameters are not supported when the case connector feature is disabled' + ); + } +} diff --git a/x-pack/plugins/cases/server/connectors/case/index.test.ts b/x-pack/plugins/cases/server/connectors/case/index.test.ts index 2415569392125..7b8f57bf0d3bf 100644 --- a/x-pack/plugins/cases/server/connectors/case/index.test.ts +++ b/x-pack/plugins/cases/server/connectors/case/index.test.ts @@ -18,43 +18,33 @@ import { AssociationType, CaseResponse, CasesResponse, -} from '../../../common'; -import { - connectorMappingsServiceMock, - createCaseServiceMock, - createConfigureServiceMock, - createUserActionServiceMock, - createAlertServiceMock, -} from '../../services/mocks'; +} from '../../../common/api'; import { CaseActionType, CaseActionTypeExecutorOptions, CaseExecutorParams } from './types'; import { getActionType } from '.'; -import { createExternalCasesClientMock } from '../../client/mocks'; - -const mockCasesClient = createExternalCasesClientMock(); -jest.mock('../../client', () => ({ - createExternalCasesClient: () => mockCasesClient, -})); +import { + CasesClientMock, + createCasesClientFactory, + createCasesClientMock, +} from '../../client/mocks'; +import { SECURITY_SOLUTION_OWNER } from '../../../common'; const services = actionsMock.createServices(); let caseActionType: CaseActionType; describe('case connector', () => { + let mockCasesClient: CasesClientMock; + beforeEach(() => { - jest.resetAllMocks(); const logger = loggingSystemMock.create().get() as jest.Mocked; - const caseService = createCaseServiceMock(); - const caseConfigureService = createConfigureServiceMock(); - const connectorMappingsService = connectorMappingsServiceMock(); - const userActionService = createUserActionServiceMock(); - const alertsService = createAlertServiceMock(); + + mockCasesClient = createCasesClientMock(); + + const factory = createCasesClientFactory(); + factory.create.mockReturnValue(Promise.resolve(mockCasesClient)); caseActionType = getActionType({ logger, - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, + factory, }); }); @@ -764,6 +754,7 @@ describe('case connector', () => { comment: { comment: 'a comment', type: CommentType.user, + owner: SECURITY_SOLUTION_OWNER, }, }, }; @@ -784,6 +775,7 @@ describe('case connector', () => { id: null, name: null, }, + owner: SECURITY_SOLUTION_OWNER, }, }, }; @@ -912,6 +904,7 @@ describe('case connector', () => { } }); + // Enable these when the actions framework provides a request and a saved objects service // ENABLE_CASE_CONNECTOR: enable these tests after the case connector feature is completed describe.skip('execute', () => { it('allows only supported sub-actions', async () => { @@ -966,9 +959,10 @@ describe('case connector', () => { settings: { syncAlerts: true, }, + owner: SECURITY_SOLUTION_OWNER, }; - mockCasesClient.create.mockReturnValue(Promise.resolve(createReturn)); + mockCasesClient.cases.create.mockReturnValue(Promise.resolve(createReturn)); const actionId = 'some-id'; const params: CaseExecutorParams = { @@ -1004,7 +998,7 @@ describe('case connector', () => { const result = await caseActionType.executor(executorOptions); expect(result).toEqual({ actionId, status: 'ok', data: createReturn }); - expect(mockCasesClient.create).toHaveBeenCalledWith({ + expect(mockCasesClient.cases.create).toHaveBeenCalledWith({ ...params.subActionParams, connector: { id: 'jira', @@ -1062,10 +1056,11 @@ describe('case connector', () => { settings: { syncAlerts: true, }, + owner: SECURITY_SOLUTION_OWNER, }, ]; - mockCasesClient.update.mockReturnValue(Promise.resolve(updateReturn)); + mockCasesClient.cases.update.mockReturnValue(Promise.resolve(updateReturn)); const actionId = 'some-id'; const params: CaseExecutorParams = { @@ -1093,7 +1088,7 @@ describe('case connector', () => { const result = await caseActionType.executor(executorOptions); expect(result).toEqual({ actionId, status: 'ok', data: updateReturn }); - expect(mockCasesClient.update).toHaveBeenCalledWith({ + expect(mockCasesClient.cases.update).toHaveBeenCalledWith({ // Null values have been striped out. cases: [ { @@ -1113,7 +1108,6 @@ describe('case connector', () => { totalComment: 0, totalAlerts: 0, version: 'WzksMV0=', - closed_at: null, closed_by: null, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, @@ -1143,6 +1137,7 @@ describe('case connector', () => { username: 'awesome', }, id: 'mock-comment', + owner: SECURITY_SOLUTION_OWNER, pushed_at: null, pushed_by: null, updated_at: null, @@ -1153,9 +1148,10 @@ describe('case connector', () => { settings: { syncAlerts: true, }, + owner: SECURITY_SOLUTION_OWNER, }; - mockCasesClient.addComment.mockReturnValue(Promise.resolve(commentReturn)); + mockCasesClient.attachments.add.mockReturnValue(Promise.resolve(commentReturn)); const actionId = 'some-id'; const params: CaseExecutorParams = { @@ -1165,6 +1161,7 @@ describe('case connector', () => { comment: { comment: 'a comment', type: CommentType.user, + owner: SECURITY_SOLUTION_OWNER, }, }, }; @@ -1180,7 +1177,7 @@ describe('case connector', () => { const result = await caseActionType.executor(executorOptions); expect(result).toEqual({ actionId, status: 'ok', data: commentReturn }); - expect(mockCasesClient.addComment).toHaveBeenCalledWith({ + expect(mockCasesClient.attachments.add).toHaveBeenCalledWith({ caseId: 'case-id', comment: { comment: 'a comment', diff --git a/x-pack/plugins/cases/server/connectors/case/index.ts b/x-pack/plugins/cases/server/connectors/case/index.ts index be519f97f2343..4a706d8fcb52c 100644 --- a/x-pack/plugins/cases/server/connectors/case/index.ts +++ b/x-pack/plugins/cases/server/connectors/case/index.ts @@ -8,8 +8,12 @@ import { curry } from 'lodash'; import { Logger } from 'src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; -import { CasePatchRequest, CasePostRequest, CommentRequest, CommentType } from '../../../common'; -import { createExternalCasesClient } from '../../client'; +import { + CasePatchRequest, + CasePostRequest, + CommentRequest, + CommentType, +} from '../../../common/api'; import { CaseExecutorParamsSchema, CaseConfigurationSchema, CommentSchemaType } from './schema'; import { CaseExecutorResponse, @@ -20,21 +24,14 @@ import { import * as i18n from './translations'; import { GetActionTypeParams, isCommentGeneratedAlert, separator } from '..'; -import { nullUser } from '../../common'; import { createCaseError } from '../../common/error'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; +import { CasesClient } from '../../client'; const supportedSubActions: string[] = ['create', 'update', 'addComment']; // action type definition -export function getActionType({ - logger, - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, -}: GetActionTypeParams): CaseActionType { +export function getActionType({ logger, factory }: GetActionTypeParams): CaseActionType { return { id: '.case', minimumLicenseRequired: 'basic', @@ -44,26 +41,15 @@ export function getActionType({ params: CaseExecutorParamsSchema, }, executor: curry(executor)({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, + factory, logger, - userActionService, }), }; } // action executor async function executor( - { - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - logger, - userActionService, - }: GetActionTypeParams, + { logger, factory }: GetActionTypeParams, execOptions: CaseActionTypeExecutorOptions ): Promise> { if (!ENABLE_CASE_CONNECTOR) { @@ -72,23 +58,11 @@ async function executor( throw new Error(msg); } - const { actionId, params, services } = execOptions; + const { actionId, params } = execOptions; const { subAction, subActionParams } = params; let data: CaseExecutorResponse | null = null; - const { savedObjectsClient, scopedClusterClient } = services; - const casesClient = createExternalCasesClient({ - savedObjectsClient, - scopedClusterClient, - // we might want the user information to be passed as part of the action request - user: nullUser, - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, - logger, - }); + let casesClient: CasesClient | undefined; if (!supportedSubActions.includes(subAction)) { const errorMessage = `[Action][Case] subAction ${subAction} not implemented.`; @@ -96,54 +70,57 @@ async function executor( throw new Error(errorMessage); } - if (subAction === 'create') { - try { - data = await casesClient.create({ - ...(subActionParams as CasePostRequest), - }); - } catch (error) { - throw createCaseError({ - message: `Failed to create a case using connector: ${error}`, - error, - logger, - }); + // When the actions framework provides the request and a way to retrieve the saved objects client with access to our + // hidden types then remove this outer if block and initialize the casesClient using the factory. + if (casesClient) { + if (subAction === 'create') { + try { + data = await casesClient.cases.create({ + ...(subActionParams as CasePostRequest), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to create a case using connector: ${error}`, + error, + logger, + }); + } } - } - if (subAction === 'update') { - const updateParamsWithoutNullValues = Object.entries(subActionParams).reduce( - (acc, [key, value]) => ({ - ...acc, - ...(value != null ? { [key]: value } : {}), - }), - {} as CasePatchRequest - ); + if (subAction === 'update') { + const updateParamsWithoutNullValues = Object.entries(subActionParams).reduce( + (acc, [key, value]) => ({ + ...acc, + ...(value != null ? { [key]: value } : {}), + }), + {} as CasePatchRequest + ); - try { - data = await casesClient.update({ cases: [updateParamsWithoutNullValues] }); - } catch (error) { - throw createCaseError({ - message: `Failed to update case using connector id: ${updateParamsWithoutNullValues?.id} version: ${updateParamsWithoutNullValues?.version}: ${error}`, - error, - logger, - }); + try { + data = await casesClient.cases.update({ cases: [updateParamsWithoutNullValues] }); + } catch (error) { + throw createCaseError({ + message: `Failed to update case using connector id: ${updateParamsWithoutNullValues?.id} version: ${updateParamsWithoutNullValues?.version}: ${error}`, + error, + logger, + }); + } } - } - if (subAction === 'addComment') { - const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - try { - const formattedComment = transformConnectorComment(comment, logger); - data = await casesClient.addComment({ caseId, comment: formattedComment }); - } catch (error) { - throw createCaseError({ - message: `Failed to create comment using connector case id: ${caseId}: ${error}`, - error, - logger, - }); + if (subAction === 'addComment') { + const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; + try { + const formattedComment = transformConnectorComment(comment, logger); + data = await casesClient.attachments.add({ caseId, comment: formattedComment }); + } catch (error) { + throw createCaseError({ + message: `Failed to create comment using connector case id: ${caseId}: ${error}`, + error, + logger, + }); + } } } - return { status: 'ok', data: data ?? {}, actionId }; } @@ -200,6 +177,7 @@ export const transformConnectorComment = ( alertId: ids, index: indices, rule, + owner: comment.owner, }; } catch (e) { throw createCaseError({ diff --git a/x-pack/plugins/cases/server/connectors/case/schema.ts b/x-pack/plugins/cases/server/connectors/case/schema.ts index 803b01cbbdc57..596a5a4aae45e 100644 --- a/x-pack/plugins/cases/server/connectors/case/schema.ts +++ b/x-pack/plugins/cases/server/connectors/case/schema.ts @@ -15,11 +15,13 @@ export const CaseConfigurationSchema = schema.object({}); const ContextTypeUserSchema = schema.object({ type: schema.literal(CommentType.user), comment: schema.string(), + owner: schema.string(), }); const ContextTypeAlertGroupSchema = schema.object({ type: schema.literal(CommentType.generatedAlert), alerts: schema.string(), + owner: schema.string(), }); export type ContextTypeGeneratedAlertType = typeof ContextTypeAlertGroupSchema.type; @@ -33,6 +35,7 @@ const ContextTypeAlertSchema = schema.object({ id: schema.nullable(schema.string()), name: schema.nullable(schema.string()), }), + owner: schema.string(), }); export type ContextTypeAlertSchemaType = typeof ContextTypeAlertSchema.type; diff --git a/x-pack/plugins/cases/server/connectors/index.ts b/x-pack/plugins/cases/server/connectors/index.ts index ecf04e4f7b0f1..4b6f845a961f2 100644 --- a/x-pack/plugins/cases/server/connectors/index.ts +++ b/x-pack/plugins/cases/server/connectors/index.ts @@ -29,22 +29,14 @@ export { transformConnectorComment } from './case'; export const separator = '__SEPARATOR__'; export const registerConnectors = ({ - actionsRegisterType, + registerActionType, logger, - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, + factory, }: RegisterConnectorsArgs) => { - actionsRegisterType( + registerActionType( getCaseConnector({ logger, - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, + factory, }) ); }; diff --git a/x-pack/plugins/cases/server/connectors/types.ts b/x-pack/plugins/cases/server/connectors/types.ts index fae1ec2976bc0..98cbe9683546b 100644 --- a/x-pack/plugins/cases/server/connectors/types.ts +++ b/x-pack/plugins/cases/server/connectors/types.ts @@ -6,22 +6,10 @@ */ import { Logger } from 'kibana/server'; -import { - ActionTypeConfig, - ActionTypeSecrets, - ActionTypeParams, - ActionType, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../actions/server/types'; -import { CaseResponse, ConnectorTypes } from '../../common'; +import { CaseResponse, ConnectorTypes } from '../../common/api'; import { CasesClientGetAlertsResponse } from '../client/alerts/types'; -import { - CaseServiceSetup, - CaseConfigureServiceSetup, - CaseUserActionServiceSetup, - ConnectorMappingsServiceSetup, - AlertServiceContract, -} from '../services'; +import { CasesClientFactory } from '../client/factory'; +import { RegisterActionType } from '../types'; export { ContextTypeGeneratedAlertType, @@ -31,22 +19,11 @@ export { export interface GetActionTypeParams { logger: Logger; - caseService: CaseServiceSetup; - caseConfigureService: CaseConfigureServiceSetup; - connectorMappingsService: ConnectorMappingsServiceSetup; - userActionService: CaseUserActionServiceSetup; - alertsService: AlertServiceContract; + factory: CasesClientFactory; } export interface RegisterConnectorsArgs extends GetActionTypeParams { - actionsRegisterType< - Config extends ActionTypeConfig = ActionTypeConfig, - Secrets extends ActionTypeSecrets = ActionTypeSecrets, - Params extends ActionTypeParams = ActionTypeParams, - ExecutorResultData = void - >( - actionType: ActionType - ): void; + registerActionType: RegisterActionType; } export type FormatterConnectorTypes = Exclude; diff --git a/x-pack/plugins/cases/server/index.ts b/x-pack/plugins/cases/server/index.ts index 628a39ba77489..0e4554572aad9 100644 --- a/x-pack/plugins/cases/server/index.ts +++ b/x-pack/plugins/cases/server/index.ts @@ -6,6 +6,7 @@ */ import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server'; +export { CasesClient } from './client'; import { ConfigType, ConfigSchema } from './config'; import { CasePlugin } from './plugin'; @@ -18,3 +19,5 @@ export const config: PluginConfigDescriptor = { }; export const plugin = (initializerContext: PluginInitializerContext) => new CasePlugin(initializerContext); + +export { PluginStartContract } from './plugin'; diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 407d6583e5f3f..34cf71aff58ba 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -8,9 +8,12 @@ import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; -import { SecurityPluginSetup } from '../../security/server'; -import { PluginSetupContract as ActionsPluginSetup } from '../../actions/server'; -import { APP_ID, ENABLE_CASE_CONNECTOR } from '../common'; +import { SecurityPluginSetup, SecurityPluginStart } from '../../security/server'; +import { + PluginSetupContract as ActionsPluginSetup, + PluginStartContract as ActionsPluginStart, +} from '../../actions/server'; +import { APP_ID, ENABLE_CASE_CONNECTOR } from '../common/constants'; import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; @@ -22,41 +25,51 @@ import { caseUserActionSavedObjectType, subCaseSavedObjectType, } from './saved_object_types'; -import { - CaseConfigureService, - CaseConfigureServiceSetup, - CaseService, - CaseServiceSetup, - CaseUserActionService, - CaseUserActionServiceSetup, - ConnectorMappingsService, - ConnectorMappingsServiceSetup, - AlertService, - AlertServiceContract, -} from './services'; -import { CasesClientHandler, createExternalCasesClient } from './client'; + +import { CasesClient } from './client'; import { registerConnectors } from './connectors'; import type { CasesRequestHandlerContext } from './types'; +import { CasesClientFactory } from './client/factory'; +import { SpacesPluginStart } from '../../spaces/server'; +import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; function createConfig(context: PluginInitializerContext) { return context.config.get(); } export interface PluginsSetup { - security: SecurityPluginSetup; + security?: SecurityPluginSetup; actions: ActionsPluginSetup; } +export interface PluginsStart { + security?: SecurityPluginStart; + features: FeaturesPluginStart; + spaces?: SpacesPluginStart; + actions: ActionsPluginStart; +} + +/** + * Cases server exposed contract for interacting with cases entities. + */ +export interface PluginStartContract { + /** + * Returns a client which can be used to interact with the cases backend entities. + * + * @param request a KibanaRequest + * @returns a {@link CasesClient} + */ + getCasesClientWithRequest(request: KibanaRequest): Promise; +} + export class CasePlugin { private readonly log: Logger; - private caseConfigureService?: CaseConfigureServiceSetup; - private caseService?: CaseServiceSetup; - private connectorMappingsService?: ConnectorMappingsServiceSetup; - private userActionService?: CaseUserActionServiceSetup; - private alertsService?: AlertService; + private clientFactory: CasesClientFactory; + private securityPluginSetup?: SecurityPluginSetup; constructor(private readonly initializerContext: PluginInitializerContext) { this.log = this.initializerContext.logger.get(); + this.clientFactory = new CasesClientFactory(this.log); } public async setup(core: CoreSetup, plugins: PluginsSetup) { @@ -66,6 +79,8 @@ export class CasePlugin { return; } + this.securityPluginSetup = plugins.security; + core.savedObjects.registerType(caseCommentSavedObjectType); core.savedObjects.registerType(caseConfigureSavedObjectType); core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); @@ -78,75 +93,54 @@ export class CasePlugin { )}] and plugins [${Object.keys(plugins)}]` ); - this.caseService = new CaseService( - this.log, - plugins.security != null ? plugins.security.authc : undefined - ); - this.caseConfigureService = await new CaseConfigureService(this.log).setup(); - this.connectorMappingsService = await new ConnectorMappingsService(this.log).setup(); - this.userActionService = await new CaseUserActionService(this.log).setup(); - this.alertsService = new AlertService(); - core.http.registerRouteHandlerContext( APP_ID, this.createRouteHandlerContext({ core, - caseService: this.caseService, - caseConfigureService: this.caseConfigureService, - connectorMappingsService: this.connectorMappingsService, - userActionService: this.userActionService, - alertsService: this.alertsService, - logger: this.log, }) ); const router = core.http.createRouter(); initCaseApi({ logger: this.log, - caseService: this.caseService, - caseConfigureService: this.caseConfigureService, - connectorMappingsService: this.connectorMappingsService, - userActionService: this.userActionService, router, }); if (ENABLE_CASE_CONNECTOR) { core.savedObjects.registerType(subCaseSavedObjectType); registerConnectors({ - actionsRegisterType: plugins.actions.registerType, + registerActionType: plugins.actions.registerType, logger: this.log, - caseService: this.caseService, - caseConfigureService: this.caseConfigureService, - connectorMappingsService: this.connectorMappingsService, - userActionService: this.userActionService, - alertsService: this.alertsService, + factory: this.clientFactory, }); } } - public start(core: CoreStart) { + public start(core: CoreStart, plugins: PluginsStart): PluginStartContract { this.log.debug(`Starting Case Workflow`); - const getCasesClientWithRequestAndContext = async ( - context: CasesRequestHandlerContext, - request: KibanaRequest - ) => { - const user = await this.caseService!.getUser({ request }); - return createExternalCasesClient({ - scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, - savedObjectsClient: core.savedObjects.getScopedClient(request), - user, - caseService: this.caseService!, - caseConfigureService: this.caseConfigureService!, - connectorMappingsService: this.connectorMappingsService!, - userActionService: this.userActionService!, - alertsService: this.alertsService!, - logger: this.log, + this.clientFactory.initialize({ + securityPluginSetup: this.securityPluginSetup, + securityPluginStart: plugins.security, + getSpace: async (request: KibanaRequest) => { + return plugins.spaces?.spacesService.getActiveSpace(request); + }, + featuresPluginStart: plugins.features, + actionsPluginStart: plugins.actions, + }); + + 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 { - getCasesClientWithRequestAndContext, + getCasesClientWithRequest, }; } @@ -156,36 +150,18 @@ export class CasePlugin { private createRouteHandlerContext = ({ core, - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, - logger, }: { core: CoreSetup; - caseService: CaseServiceSetup; - caseConfigureService: CaseConfigureServiceSetup; - connectorMappingsService: ConnectorMappingsServiceSetup; - userActionService: CaseUserActionServiceSetup; - alertsService: AlertServiceContract; - logger: Logger; }): IContextProvider => { return async (context, request, response) => { - const [{ savedObjects }] = await core.getStartServices(); - const user = await caseService.getUser({ request }); return { - getCasesClient: () => { - return new CasesClientHandler({ + getCasesClient: async () => { + const [{ savedObjects }] = await core.getStartServices(); + + return this.clientFactory.create({ + request, scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, - savedObjectsClient: savedObjects.getScopedClient(request), - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, - user, - logger, + savedObjectsService: savedObjects, }); }, }; diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/authc_mock.ts index 66d3ffe5f23d1..a9292229d5eea 100644 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/cases/server/routes/api/__fixtures__/authc_mock.ts @@ -16,7 +16,7 @@ function createAuthenticationMock({ authc.getCurrentUser.mockReturnValue( currentUser !== undefined ? // if we pass in null then use the null user (has null for each field) this is the default behavior - // for the CaseService getUser method + // for the CasesService getUser method currentUser !== null ? currentUser : nullUser diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/create_mock_so_repository.ts deleted file mode 100644 index a33226bcde899..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ /dev/null @@ -1,305 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - SavedObjectsClientContract, - SavedObjectsErrorHelpers, - SavedObjectsBulkGetObject, - SavedObjectsBulkUpdateObject, - SavedObjectsFindOptions, -} from 'src/core/server'; - -import { - CASE_COMMENT_SAVED_OBJECT, - CASE_SAVED_OBJECT, - CASE_CONFIGURE_SAVED_OBJECT, - CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, - CASE_USER_ACTION_SAVED_OBJECT, -} from '../../../saved_object_types'; - -export const createMockSavedObjectsRepository = ({ - caseSavedObject = [], - caseCommentSavedObject = [], - caseConfigureSavedObject = [], - caseMappingsSavedObject = [], - caseUserActionsSavedObject = [], -}: { - caseSavedObject?: any[]; - caseCommentSavedObject?: any[]; - caseConfigureSavedObject?: any[]; - caseMappingsSavedObject?: any[]; - caseUserActionsSavedObject?: any[]; -} = {}) => { - const mockSavedObjectsClientContract = ({ - bulkGet: jest.fn((objects: SavedObjectsBulkGetObject[]) => { - return { - saved_objects: objects.map(({ id, type }) => { - if (type === CASE_COMMENT_SAVED_OBJECT) { - const result = caseCommentSavedObject.filter((s) => s.id === id); - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return result; - } - const result = caseSavedObject.filter((s) => s.id === id); - if (!result.length) { - return { - id, - type, - error: { - statusCode: 404, - error: 'Not Found', - message: 'Saved object [cases/not-exist] not found', - }, - }; - } - return result[0]; - }), - }; - }), - bulkCreate: jest.fn(), - bulkUpdate: jest.fn((objects: Array>) => { - return { - saved_objects: objects.map(({ id, type, attributes }) => { - if (type === CASE_COMMENT_SAVED_OBJECT) { - if (!caseCommentSavedObject.find((s) => s.id === id)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - } else if (type === CASE_SAVED_OBJECT) { - if (!caseSavedObject.find((s) => s.id === id)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - } - - return { - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - version: 'WzE3LDFd', - attributes, - }; - }), - }; - }), - get: jest.fn((type, id) => { - if (type === CASE_COMMENT_SAVED_OBJECT) { - const result = caseCommentSavedObject.filter((s) => s.id === id); - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return result[0]; - } else if (type === CASE_SAVED_OBJECT) { - const result = caseSavedObject.filter((s) => s.id === id); - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return result[0]; - } else { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - }), - find: jest.fn((findArgs: SavedObjectsFindOptions) => { - // References can be an array so we need to loop through it looking for the bad-guy - const hasReferenceIncludeBadGuy = (args: SavedObjectsFindOptions) => { - const references = args.hasReference; - if (references) { - return Array.isArray(references) - ? references.some((ref) => ref.id === 'bad-guy') - : references.id === 'bad-guy'; - } else { - return false; - } - }; - if (hasReferenceIncludeBadGuy(findArgs)) { - throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); - } - - if ( - (findArgs.type === CASE_CONFIGURE_SAVED_OBJECT && - caseConfigureSavedObject[0] && - caseConfigureSavedObject[0].id === 'throw-error-find') || - (findArgs.type === CASE_SAVED_OBJECT && - caseSavedObject[0] && - caseSavedObject[0].id === 'throw-error-find') - ) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError('Error thrown for testing'); - } - if (findArgs.type === CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT && caseMappingsSavedObject[0]) { - return { - page: 1, - per_page: 5, - total: 1, - saved_objects: caseMappingsSavedObject, - }; - } - - if (findArgs.type === CASE_CONFIGURE_SAVED_OBJECT) { - return { - page: 1, - per_page: 5, - total: caseConfigureSavedObject.length, - saved_objects: caseConfigureSavedObject, - }; - } - - if (findArgs.type === CASE_COMMENT_SAVED_OBJECT) { - return { - page: 1, - per_page: 5, - total: caseCommentSavedObject.length, - saved_objects: caseCommentSavedObject, - }; - } - - // Currently not supporting sub cases in this mock library - if (findArgs.type === SUB_CASE_SAVED_OBJECT) { - return { - page: 1, - per_page: 0, - total: 0, - saved_objects: [], - }; - } - - if (findArgs.type === CASE_USER_ACTION_SAVED_OBJECT) { - return { - page: 1, - per_page: 5, - total: caseUserActionsSavedObject.length, - saved_objects: caseUserActionsSavedObject, - }; - } - - return { - page: 1, - per_page: 5, - total: caseSavedObject.length, - saved_objects: caseSavedObject, - }; - }), - create: jest.fn((type, attributes, references) => { - if (attributes.description === 'Throw an error' || attributes.comment === 'Throw an error') { - throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); - } - - if ( - type === CASE_CONFIGURE_SAVED_OBJECT && - attributes.connector.id === 'throw-error-create' - ) { - throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); - } - - if (type === CASE_COMMENT_SAVED_OBJECT) { - const newCommentObj = { - type, - id: 'mock-comment', - attributes, - ...references, - updated_at: '2019-12-02T22:48:08.327Z', - version: 'WzksMV0=', - }; - caseCommentSavedObject = [...caseCommentSavedObject, newCommentObj]; - return newCommentObj; - } - - if (type === CASE_CONFIGURE_SAVED_OBJECT) { - const newConfiguration = { - type, - id: 'mock-configuration', - attributes, - updated_at: '2020-04-09T09:43:51.778Z', - version: attributes.connector.id === 'no-version' ? undefined : 'WzksMV0=', - }; - - caseConfigureSavedObject = [newConfiguration]; - return newConfiguration; - } - - return { - type, - id: 'mock-it', - attributes, - references: [], - updated_at: '2019-12-02T22:48:08.327Z', - version: 'WzksMV0=', - }; - }), - update: jest.fn((type, id, attributes) => { - if (type === CASE_COMMENT_SAVED_OBJECT) { - const foundComment = caseCommentSavedObject.findIndex((s: { id: string }) => s.id === id); - if (foundComment === -1) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - const comment = caseCommentSavedObject[foundComment]; - caseCommentSavedObject.splice(foundComment, 1, { - ...comment, - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - version: 'WzE3LDFd', - attributes: { - ...comment.attributes, - ...attributes, - }, - }); - } else if (type === CASE_SAVED_OBJECT) { - if (!caseSavedObject.find((s) => s.id === id)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - } - - if (type === CASE_CONFIGURE_SAVED_OBJECT) { - return { - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - attributes, - version: attributes.connector?.id === 'no-version' ? undefined : 'WzE3LDFd', - }; - } - - return { - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - version: 'WzE3LDFd', - attributes, - }; - }), - delete: jest.fn((type: string, id: string) => { - let result = caseSavedObject.filter((s) => s.id === id); - - if (type === CASE_COMMENT_SAVED_OBJECT) { - result = caseCommentSavedObject.filter((s) => s.id === id); - } - - if (type === CASE_CONFIGURE_SAVED_OBJECT) { - result = caseConfigureSavedObject.filter((s) => s.id === id); - } - - if (type === CASE_COMMENT_SAVED_OBJECT && id === 'bad-guy') { - throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); - } - - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - - if ( - type === CASE_CONFIGURE_SAVED_OBJECT && - caseConfigureSavedObject[0].id === 'throw-error-delete' - ) { - throw new Error('Error thrown for testing'); - } - return {}; - }), - deleteByNamespace: jest.fn(), - } as unknown) as jest.Mocked; - - return mockSavedObjectsClientContract; -}; diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/index.ts index 1abd44aec1552..25f9b05471a0d 100644 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/index.ts +++ b/x-pack/plugins/cases/server/routes/api/__fixtures__/index.ts @@ -6,8 +6,4 @@ */ export * from './mock_saved_objects'; -export { createMockSavedObjectsRepository } from './create_mock_so_repository'; -export { createRouteContext } from './route_contexts'; export { authenticationMock } from './authc_mock'; -export { createRoute } from './mock_router'; -export { createActionsClient } from './mock_actions_client'; diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_actions_client.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_actions_client.ts deleted file mode 100644 index d153c328cbb91..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_actions_client.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { SavedObjectsErrorHelpers } from 'src/core/server'; -import { actionsClientMock } from '../../../../../actions/server/mocks'; -import { - getActions, - getActionTypes, - getActionExecuteResults, -} from '../__mocks__/request_responses'; - -export const createActionsClient = () => { - const actionsMock = actionsClientMock.create(); - actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); - actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes())); - actionsMock.get.mockImplementation(({ id }) => { - const actions = getActions(); - const action = actions.find((a) => a.id === id); - if (action) { - return Promise.resolve(action); - } else { - return Promise.reject(SavedObjectsErrorHelpers.createGenericNotFoundError('action', id)); - } - }); - actionsMock.execute.mockImplementation(({ actionId }) => - Promise.resolve(getActionExecuteResults(actionId)) - ); - - return actionsMock; -}; diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_router.ts deleted file mode 100644 index 18cce1b087e5d..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_router.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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, httpServiceMock } from '../../../../../../../src/core/server/mocks'; -import { CaseService, CaseConfigureService, ConnectorMappingsService } from '../../../services'; -import { authenticationMock } from '../__fixtures__'; -import { RouteDeps } from '../types'; - -export const createRoute = async ( - api: (deps: RouteDeps) => void, - method: 'get' | 'post' | 'delete' | 'patch', - badAuth = false -) => { - const httpService = httpServiceMock.createSetupContract(); - const router = httpService.createRouter(); - - const log = loggingSystemMock.create().get('cases'); - const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); - const caseService = new CaseService(log, auth); - const caseConfigureServicePlugin = new CaseConfigureService(log); - const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - const caseConfigureService = await caseConfigureServicePlugin.setup(); - const connectorMappingsService = await connectorMappingsServicePlugin.setup(); - - api({ - caseConfigureService, - caseService, - connectorMappingsService, - router, - userActionService: { - postUserActions: jest.fn(), - getUserActions: jest.fn(), - }, - logger: log, - }); - - return router[method].mock.calls[0][1]; -}; diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts index 0026ee9ce4827..bddceef8d782e 100644 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -21,7 +21,8 @@ import { import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, CASE_USER_ACTION_SAVED_OBJECT, -} from '../../../saved_object_types'; + SECURITY_SOLUTION_OWNER, +} from '../../../../common/constants'; import { mappings } from '../../../client/configure/mock'; export const mockCases: Array> = [ @@ -58,6 +59,7 @@ export const mockCases: Array> = [ settings: { syncAlerts: true, }, + owner: SECURITY_SOLUTION_OWNER, }, references: [], updated_at: '2019-11-25T21:54:48.952Z', @@ -96,6 +98,7 @@ export const mockCases: Array> = [ settings: { syncAlerts: true, }, + owner: SECURITY_SOLUTION_OWNER, }, references: [], updated_at: '2019-11-25T22:32:00.900Z', @@ -138,6 +141,7 @@ export const mockCases: Array> = [ settings: { syncAlerts: true, }, + owner: SECURITY_SOLUTION_OWNER, }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -184,6 +188,7 @@ export const mockCases: Array> = [ settings: { syncAlerts: true, }, + owner: SECURITY_SOLUTION_OWNER, }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -246,6 +251,7 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + owner: SECURITY_SOLUTION_OWNER, pushed_at: null, pushed_by: null, updated_at: '2019-11-25T21:55:00.177Z', @@ -278,6 +284,7 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + owner: SECURITY_SOLUTION_OWNER, pushed_at: null, pushed_by: null, updated_at: '2019-11-25T21:55:14.633Z', @@ -311,6 +318,7 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + owner: SECURITY_SOLUTION_OWNER, pushed_at: null, pushed_by: null, updated_at: '2019-11-25T22:32:30.608Z', @@ -344,6 +352,7 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + owner: SECURITY_SOLUTION_OWNER, pushed_at: null, pushed_by: null, rule: { @@ -381,6 +390,7 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + owner: SECURITY_SOLUTION_OWNER, pushed_at: null, pushed_by: null, rule: { @@ -418,6 +428,7 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + owner: SECURITY_SOLUTION_OWNER, pushed_at: null, pushed_by: null, rule: { @@ -467,6 +478,7 @@ export const mockCaseConfigure: Array> = email: 'testemail@elastic.co', username: 'elastic', }, + owner: SECURITY_SOLUTION_OWNER, }, references: [], updated_at: '2020-04-09T09:43:51.778Z', @@ -480,6 +492,7 @@ export const mockCaseMappings: Array> = [ id: 'mock-mappings-1', attributes: { mappings: mappings[ConnectorTypes.jira], + owner: SECURITY_SOLUTION_OWNER, }, references: [], }, @@ -491,6 +504,7 @@ export const mockCaseMappingsResilient: Array> = id: 'mock-mappings-1', attributes: { mappings: mappings[ConnectorTypes.resilient], + owner: SECURITY_SOLUTION_OWNER, }, references: [], }, @@ -521,6 +535,7 @@ export const mockUserActions: Array> = [ new_value: '{"title":"A case","tags":["case"],"description":"Yeah!","connector":{"id":"connector-od","name":"My Connector","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}', old_value: null, + owner: SECURITY_SOLUTION_OWNER, }, version: 'WzYsMV0=', references: [], @@ -540,6 +555,7 @@ export const mockUserActions: Array> = [ new_value: '{"type":"alert","alertId":"cec3da90fb37a44407145adf1593f3b0d5ad94c4654201f773d63b5d4706128e","index":".siem-signals-default-000008"}', old_value: null, + owner: SECURITY_SOLUTION_OWNER, }, version: 'WzYsMV0=', references: [], diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/route_contexts.ts deleted file mode 100644 index 42e8561c2ac54..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/route_contexts.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; -import { createExternalCasesClient } from '../../../client'; -import { - AlertService, - CaseService, - CaseConfigureService, - ConnectorMappingsService, - CaseUserActionService, -} from '../../../services'; -import { authenticationMock } from '../__fixtures__'; -import type { CasesRequestHandlerContext } from '../../../types'; -import { createActionsClient } from './mock_actions_client'; - -export const createRouteContext = async (client: any, badAuth = false) => { - const actionsMock = createActionsClient(); - - const log = loggingSystemMock.create().get('case'); - const esClient = elasticsearchServiceMock.createElasticsearchClient(); - - const authc = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); - - const caseService = new CaseService(log, authc); - const caseConfigureServicePlugin = new CaseConfigureService(log); - const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - const caseUserActionsServicePlugin = new CaseUserActionService(log); - - const caseConfigureService = await caseConfigureServicePlugin.setup(); - const userActionService = await caseUserActionsServicePlugin.setup(); - const alertsService = new AlertService(); - - const context = ({ - core: { - savedObjects: { - client, - }, - }, - actions: { getActionsClient: () => actionsMock }, - cases: { - getCasesClient: () => casesClient, - }, - } as unknown) as CasesRequestHandlerContext; - - const connectorMappingsService = await connectorMappingsServicePlugin.setup(); - const casesClient = createExternalCasesClient({ - savedObjectsClient: client, - user: authc.getCurrentUser(), - caseService, - caseConfigureService, - connectorMappingsService, - userActionService, - alertsService, - scopedClusterClient: esClient, - logger: log, - }); - - return { context, services: { userActionService } }; -}; diff --git a/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts index 9df94cd0923c9..f3e6bcd7fc9ff 100644 --- a/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/cases/server/routes/api/__mocks__/request_responses.ts @@ -5,14 +5,7 @@ * 2.0. */ -import { - ActionTypeConnector, - CasePostRequest, - CasesConfigureRequest, - ConnectorTypes, -} from '../../../../common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { FindActionResult } from '../../../../../actions/server/types'; +import { SECURITY_SOLUTION_OWNER, CasePostRequest, ConnectorTypes } from '../../../../common'; export const newCase: CasePostRequest = { title: 'My new case', @@ -27,135 +20,5 @@ export const newCase: CasePostRequest = { settings: { syncAlerts: true, }, -}; - -export const getActions = (): FindActionResult[] => [ - { - id: 'e90075a5-c386-41e3-ae21-ba4e61510695', - actionTypeId: '.webhook', - name: 'Test', - config: { - method: 'post', - url: 'https://example.com', - headers: null, - }, - isPreconfigured: false, - referencedByCount: 0, - }, - { - id: '123', - actionTypeId: '.servicenow', - name: 'ServiceNow', - config: { - apiUrl: 'https://dev102283.service-now.com', - }, - isPreconfigured: false, - referencedByCount: 0, - }, - { - id: '456', - actionTypeId: '.jira', - name: 'Connector without isCaseOwned', - config: { - apiUrl: 'https://elastic.jira.com', - }, - isPreconfigured: false, - referencedByCount: 0, - }, - { - id: '789', - actionTypeId: '.resilient', - name: 'Connector without mapping', - config: { - apiUrl: 'https://elastic.resilient.com', - }, - isPreconfigured: false, - referencedByCount: 0, - }, - { - id: 'for-mock-case-id-3', - actionTypeId: '.jira', - name: 'For mock case id 3', - config: { - apiUrl: 'https://elastic.jira.com', - }, - isPreconfigured: false, - referencedByCount: 0, - }, -]; - -export const getActionTypes = (): ActionTypeConnector[] => [ - { - id: '.email', - name: 'Email', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.index', - name: 'Index', - minimumLicenseRequired: 'basic', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.servicenow', - name: 'ServiceNow', - minimumLicenseRequired: 'platinum', - enabled: false, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.jira', - name: 'Jira', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.resilient', - name: 'IBM Resilient', - minimumLicenseRequired: 'platinum', - enabled: false, - enabledInConfig: true, - enabledInLicense: true, - }, -]; - -export const getActionExecuteResults = (actionId = '123') => ({ - status: 'ok' as const, - data: { - title: 'RJ2-200', - id: '10663', - pushedDate: '2020-12-17T00:32:40.738Z', - url: 'https://siem-kibana.atlassian.net/browse/RJ2-200', - comments: [], - }, - actionId, -}); - -export const newConfiguration: CasesConfigureRequest = { - connector: { - id: '456', - name: 'My connector 2', - type: ConnectorTypes.jira, - fields: null, - }, - closure_type: 'close-by-pushing', -}; - -export const executePushResponse = { - status: 'ok', - data: { - title: 'RJ2-200', - id: '10663', - pushedDate: '2020-12-17T00:32:40.738Z', - url: 'https://siem-kibana.atlassian.net/browse/RJ2-200', - comments: [], - }, + owner: SECURITY_SOLUTION_OWNER, }; diff --git a/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts index 1fc41874fe9d5..7a65229a2a07f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts @@ -9,10 +9,11 @@ import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; import { RouteDeps } from '../../types'; -import { wrapError } from '../../utils'; +import { escapeHatch, wrapError } from '../../utils'; import { CASE_ALERTS_URL } from '../../../../../common/constants'; +import { CasesByAlertIDRequest } from '../../../../../common'; -export function initGetCaseIdsByAlertIdApi({ caseService, router, logger }: RouteDeps) { +export function initGetCaseIdsByAlertIdApi({ router, logger }: RouteDeps) { router.get( { path: CASE_ALERTS_URL, @@ -20,22 +21,20 @@ export function initGetCaseIdsByAlertIdApi({ caseService, router, logger }: Rout params: schema.object({ alert_id: schema.string(), }), + query: escapeHatch, }, }, async (context, request, response) => { try { - const alertId = request.params.alert_id; - if (alertId == null || alertId === '') { + const alertID = request.params.alert_id; + if (alertID == null || alertID === '') { throw Boom.badRequest('The `alertId` is not valid'); } - const client = context.core.savedObjects.client; - const caseIds = await caseService.getCaseIdsByAlertId({ - client, - alertId, - }); + const casesClient = await context.cases.getCasesClient(); + const options = request.query as CasesByAlertIDRequest; return response.ok({ - body: caseIds, + body: await casesClient.cases.getCaseIDsByAlertID({ alertID, options }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts deleted file mode 100644 index 1e7e875a53df3..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_all_comments.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { schema } from '@kbn/config-schema'; -import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; -import { RouteDeps } from '../../types'; -import { wrapError } from '../../utils'; -import { AssociationType, CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common'; - -export function initDeleteAllCommentsApi({ - caseService, - router, - userActionService, - logger, -}: RouteDeps) { - router.delete( - { - path: CASE_COMMENTS_URL, - validate: { - params: schema.object({ - case_id: schema.string(), - }), - query: schema.maybe( - schema.object({ - subCaseId: schema.maybe(schema.string()), - }) - ), - }, - }, - async (context, request, response) => { - try { - if (!ENABLE_CASE_CONNECTOR && request.query?.subCaseId !== undefined) { - throw Boom.badRequest( - 'The `subCaseId` is not supported when the case connector feature is disabled' - ); - } - - const client = context.core.savedObjects.client; - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const deleteDate = new Date().toISOString(); - - const subCaseId = request.query?.subCaseId; - const id = subCaseId ?? request.params.case_id; - const comments = await caseService.getCommentsByAssociation({ - client, - id, - associationType: subCaseId ? AssociationType.subCase : AssociationType.case, - }); - - await Promise.all( - comments.saved_objects.map((comment) => - caseService.deleteComment({ - client, - commentId: comment.id, - }) - ) - ); - - await userActionService.postUserActions({ - client, - actions: comments.saved_objects.map((comment) => - buildCommentUserActionItem({ - action: 'delete', - actionAt: deleteDate, - actionBy: { username, full_name, email }, - caseId: request.params.case_id, - subCaseId, - commentId: comment.id, - fields: ['comment'], - }) - ), - }); - - return response.noContent(); - } catch (error) { - logger.error( - `Failed to delete all comments in route case id: ${request.params.case_id} sub case id: ${request.query?.subCaseId}: ${error}` - ); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts deleted file mode 100644 index d0968c3232459..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, - mockCaseComments, -} from '../../__fixtures__'; -import { initDeleteCommentApi } from './delete_comment'; -import { CASE_COMMENT_DETAILS_URL } from '../../../../../common'; - -describe('DELETE comment', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initDeleteCommentApi, 'delete'); - }); - it(`deletes the comment. responds with 204`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENT_DETAILS_URL, - method: 'delete', - params: { - case_id: 'mock-id-1', - comment_id: 'mock-comment-1', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(204); - }); - it(`returns an error when thrown from deleteComment service`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENT_DETAILS_URL, - method: 'delete', - params: { - case_id: 'mock-id-1', - comment_id: 'bad-guy', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(404); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts deleted file mode 100644 index f8771f92c417f..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/delete_comment.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { schema } from '@kbn/config-schema'; - -import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; -import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; -import { RouteDeps } from '../../types'; -import { wrapError } from '../../utils'; -import { CASE_COMMENT_DETAILS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common/constants'; - -export function initDeleteCommentApi({ - caseService, - router, - userActionService, - logger, -}: RouteDeps) { - router.delete( - { - path: CASE_COMMENT_DETAILS_URL, - validate: { - params: schema.object({ - case_id: schema.string(), - comment_id: schema.string(), - }), - query: schema.maybe( - schema.object({ - subCaseId: schema.maybe(schema.string()), - }) - ), - }, - }, - async (context, request, response) => { - try { - if (!ENABLE_CASE_CONNECTOR && request.query?.subCaseId !== undefined) { - throw Boom.badRequest( - 'The `subCaseId` is not supported when the case connector feature is disabled' - ); - } - - const client = context.core.savedObjects.client; - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const deleteDate = new Date().toISOString(); - - const myComment = await caseService.getComment({ - client, - commentId: request.params.comment_id, - }); - - if (myComment == null) { - throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); - } - - const type = request.query?.subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; - const id = request.query?.subCaseId ?? request.params.case_id; - - const caseRef = myComment.references.find((c) => c.type === type); - if (caseRef == null || (caseRef != null && caseRef.id !== id)) { - throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${id}).` - ); - } - - await caseService.deleteComment({ - client, - commentId: request.params.comment_id, - }); - - await userActionService.postUserActions({ - client, - actions: [ - buildCommentUserActionItem({ - action: 'delete', - actionAt: deleteDate, - actionBy: { username, full_name, email }, - caseId: id, - subCaseId: request.query?.subCaseId, - commentId: request.params.comment_id, - fields: ['comment'], - }), - ], - }); - - return response.noContent(); - } catch (error) { - logger.error( - `Failed to delete comment in route case id: ${request.params.case_id} comment id: ${request.params.comment_id} sub case id: ${request.query?.subCaseId}: ${error}` - ); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts deleted file mode 100644 index 654b8d532830a..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/find_comments.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 * as rt from 'io-ts'; - -import { schema } from '@kbn/config-schema'; -import Boom from '@hapi/boom'; - -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { - AssociationType, - CommentsResponseRt, - SavedObjectFindOptionsRt, - throwErrors, -} from '../../../../../common'; -import { RouteDeps } from '../../types'; -import { escapeHatch, transformComments, wrapError } from '../../utils'; -import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common'; -import { defaultPage, defaultPerPage } from '../..'; - -const FindQueryParamsRt = rt.partial({ - ...SavedObjectFindOptionsRt.props, - subCaseId: rt.string, -}); - -export function initFindCaseCommentsApi({ caseService, router, logger }: RouteDeps) { - router.get( - { - path: `${CASE_COMMENTS_URL}/_find`, - validate: { - params: schema.object({ - case_id: schema.string(), - }), - query: escapeHatch, - }, - }, - async (context, request, response) => { - try { - const client = context.core.savedObjects.client; - const query = pipe( - FindQueryParamsRt.decode(request.query), - fold(throwErrors(Boom.badRequest), identity) - ); - - if (!ENABLE_CASE_CONNECTOR && query.subCaseId !== undefined) { - throw Boom.badRequest( - 'The `subCaseId` is not supported when the case connector feature is disabled' - ); - } - - const id = query.subCaseId ?? request.params.case_id; - const associationType = query.subCaseId ? AssociationType.subCase : AssociationType.case; - const args = query - ? { - caseService, - client, - id, - options: { - // We need this because the default behavior of getAllCaseComments is to return all the comments - // unless the page and/or perPage is specified. Since we're spreading the query after the request can - // still override this behavior. - page: defaultPage, - perPage: defaultPerPage, - sortField: 'created_at', - ...query, - }, - associationType, - } - : { - caseService, - client, - id, - options: { - page: defaultPage, - perPage: defaultPerPage, - sortField: 'created_at', - }, - associationType, - }; - - const theComments = await caseService.getCommentsByAssociation(args); - return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); - } catch (error) { - logger.error( - `Failed to find comments in route case id: ${request.params.case_id}: ${error}` - ); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts deleted file mode 100644 index 580bb3163bb7d..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_all_comment.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { schema } from '@kbn/config-schema'; - -import { SavedObjectsFindResponse } from 'kibana/server'; -import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common'; -import { RouteDeps } from '../../types'; -import { flattenCommentSavedObjects, wrapError } from '../../utils'; -import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common'; -import { defaultSortField } from '../../../../common'; - -export function initGetAllCommentsApi({ caseService, router, logger }: RouteDeps) { - router.get( - { - path: CASE_COMMENTS_URL, - validate: { - params: schema.object({ - case_id: schema.string(), - }), - query: schema.maybe( - schema.object({ - includeSubCaseComments: schema.maybe(schema.boolean()), - subCaseId: schema.maybe(schema.string()), - }) - ), - }, - }, - async (context, request, response) => { - try { - const client = context.core.savedObjects.client; - let comments: SavedObjectsFindResponse; - - if ( - !ENABLE_CASE_CONNECTOR && - (request.query?.subCaseId !== undefined || - request.query?.includeSubCaseComments !== undefined) - ) { - throw Boom.badRequest( - 'The `subCaseId` and `includeSubCaseComments` are not supported when the case connector feature is disabled' - ); - } - - if (request.query?.subCaseId) { - comments = await caseService.getAllSubCaseComments({ - client, - id: request.query.subCaseId, - options: { - sortField: defaultSortField, - }, - }); - } else { - comments = await caseService.getAllCaseComments({ - client, - id: request.params.case_id, - includeSubCaseComments: request.query?.includeSubCaseComments, - options: { - sortField: defaultSortField, - }, - }); - } - - return response.ok({ - body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), - }); - } catch (error) { - logger.error( - `Failed to get all comments in route case id: ${request.params.case_id} include sub case comments: ${request.query?.includeSubCaseComments} sub case id: ${request.query?.subCaseId}: ${error}` - ); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts deleted file mode 100644 index 46accdc58d460..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCaseComments, - mockCases, -} from '../../__fixtures__'; -import { flattenCommentSavedObject } from '../../utils'; -import { initGetCommentApi } from './get_comment'; -import { CASE_COMMENT_DETAILS_URL } from '../../../../../common'; - -describe('GET comment', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initGetCommentApi, 'get'); - }); - it(`returns the comment`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENT_DETAILS_URL, - method: 'get', - params: { - case_id: 'mock-id-1', - comment_id: 'mock-comment-1', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - const myPayload = mockCaseComments.find((s) => s.id === 'mock-comment-1'); - expect(myPayload).not.toBeUndefined(); - if (myPayload != null) { - expect(response.payload).toEqual(flattenCommentSavedObject(myPayload)); - } - }); - it(`returns an error when getComment throws`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENT_DETAILS_URL, - method: 'get', - params: { - case_id: 'mock-id-1', - comment_id: 'not-real', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(404); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts deleted file mode 100644 index 32a0133d455c2..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { omit } from 'lodash/fp'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCaseComments, - mockCases, -} from '../../__fixtures__'; -import { initPatchCommentApi } from './patch_comment'; -import { CASE_COMMENTS_URL } from '../../../../../common'; -import { CommentType } from '../../../../../common'; - -describe('PATCH comment', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initPatchCommentApi, 'patch'); - }); - - it(`Patch a comment`, async () => { - const commentID = 'mock-comment-1'; - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'patch', - params: { - case_id: 'mock-id-1', - }, - body: { - type: CommentType.user, - comment: 'Update my comment', - id: commentID, - version: 'WzEsMV0=', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - const updatedComment = response.payload.comments.find( - (comment: { id: string }) => comment.id === commentID - ); - expect(updatedComment.comment).toEqual('Update my comment'); - }); - - it(`Patch an alert`, async () => { - const commentID = 'mock-comment-4'; - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'patch', - params: { - case_id: 'mock-id-4', - }, - body: { - type: CommentType.alert, - alertId: 'new-id', - index: 'test-index', - rule: { - id: 'rule-id', - name: 'rule', - }, - id: commentID, - version: 'WzYsMV0=', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - const updatedComment = response.payload.comments.find( - (comment: { id: string }) => comment.id === commentID - ); - expect(updatedComment.alertId).toEqual('new-id'); - }); - - it(`it throws when missing attributes: type user`, async () => { - const allRequestAttributes = { - type: CommentType.user, - comment: 'a comment', - }; - - for (const attribute of ['comment']) { - const requestAttributes = omit(attribute, allRequestAttributes); - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: requestAttributes, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - } - }); - - it(`it throws when excess attributes are provided: type user`, async () => { - for (const attribute of ['alertId', 'index']) { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: { - [attribute]: attribute, - comment: 'a comment', - type: CommentType.user, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - } - }); - - it(`it throws when missing attributes: type alert`, async () => { - const allRequestAttributes = { - type: CommentType.alert, - index: 'test-index', - alertId: 'test-id', - }; - - for (const attribute of ['alertId', 'index']) { - const requestAttributes = omit(attribute, allRequestAttributes); - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: requestAttributes, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - } - }); - - it(`it throws when excess attributes are provided: type alert`, async () => { - for (const attribute of ['comment']) { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: { - [attribute]: attribute, - type: CommentType.alert, - index: 'test-index', - alertId: 'test-id', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - } - }); - - it(`it fails to change the type of the comment`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'patch', - params: { - case_id: 'mock-id-1', - }, - body: { - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - rule: { - id: 'rule-id', - name: 'rule', - }, - id: 'mock-comment-1', - version: 'WzEsMV0=', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - expect(response.payload.message).toEqual('You cannot change the type of the comment.'); - }); - - it(`Fails with 409 if version does not match`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'patch', - params: { - case_id: 'mock-id-1', - }, - body: { - type: CommentType.user, - id: 'mock-comment-1', - comment: 'Update my comment', - version: 'badv=', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(409); - }); - - it(`Returns an error if updateComment throws`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'patch', - params: { - case_id: 'mock-id-1', - }, - body: { - type: CommentType.user, - comment: 'Update my comment', - id: 'mock-comment-does-not-exist', - version: 'WzEsMV0=', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(404); - expect(response.payload.isBoom).toEqual(true); - }); - - describe('alert format', () => { - it.each([ - ['1', ['index1', 'index2'], CommentType.alert, 'mock-comment-4'], - [['1', '2'], 'index', CommentType.alert, 'mock-comment-4'], - ['1', ['index1', 'index2'], CommentType.generatedAlert, 'mock-comment-6'], - [['1', '2'], 'index', CommentType.generatedAlert, 'mock-comment-6'], - ])( - 'returns an error with an alert comment with contents id: %p indices: %p type: %s comment id: %s', - async (alertId, index, type, commentID) => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'patch', - params: { - case_id: 'mock-id-4', - }, - body: { - type, - alertId, - index, - rule: { - id: 'rule-id', - name: 'rule', - }, - id: commentID, - version: 'WzYsMV0=', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - } - ); - - it.each([ - ['1', ['index1'], CommentType.alert], - [['1', '2'], ['index', 'other-index'], CommentType.alert], - ])( - 'does not return an error with an alert comment with contents id: %p indices: %p type: %s', - async (alertId, index, type) => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'patch', - params: { - case_id: 'mock-id-4', - }, - body: { - type, - alertId, - index, - rule: { - id: 'rule-id', - name: 'rule', - }, - id: 'mock-comment-4', - // this version is different than the one in mockCaseComments because it gets updated in place - version: 'WzE3LDFd', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - } - ); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts deleted file mode 100644 index 366fb887066f8..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/patch_comment.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { pick } from 'lodash/fp'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { schema } from '@kbn/config-schema'; -import Boom from '@hapi/boom'; - -import { SavedObjectsClientContract, Logger } from 'kibana/server'; -import { CommentableCase } from '../../../../common'; -import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common'; -import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; -import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; -import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError, decodeCommentRequest } from '../../utils'; -import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../../common'; -import { CaseServiceSetup } from '../../../../services'; - -interface CombinedCaseParams { - service: CaseServiceSetup; - client: SavedObjectsClientContract; - caseID: string; - logger: Logger; - subCaseId?: string; -} - -async function getCommentableCase({ - service, - client, - caseID, - subCaseId, - logger, -}: CombinedCaseParams) { - if (subCaseId) { - const [caseInfo, subCase] = await Promise.all([ - service.getCase({ - client, - id: caseID, - }), - service.getSubCase({ - client, - id: subCaseId, - }), - ]); - return new CommentableCase({ - collection: caseInfo, - service, - subCase, - soClient: client, - logger, - }); - } else { - const caseInfo = await service.getCase({ - client, - id: caseID, - }); - return new CommentableCase({ collection: caseInfo, service, soClient: client, logger }); - } -} - -export function initPatchCommentApi({ caseService, router, userActionService, logger }: RouteDeps) { - router.patch( - { - path: CASE_COMMENTS_URL, - validate: { - params: schema.object({ - case_id: schema.string(), - }), - query: schema.maybe( - schema.object({ - subCaseId: schema.maybe(schema.string()), - }) - ), - body: escapeHatch, - }, - }, - async (context, request, response) => { - try { - if (!ENABLE_CASE_CONNECTOR && request.query?.subCaseId !== undefined) { - throw Boom.badRequest( - 'The `subCaseId` is not supported when the case connector feature is disabled' - ); - } - - const client = context.core.savedObjects.client; - const query = pipe( - CommentPatchRequestRt.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); - - const { id: queryCommentId, version: queryCommentVersion, ...queryRestAttributes } = query; - decodeCommentRequest(queryRestAttributes); - - const commentableCase = await getCommentableCase({ - service: caseService, - client, - caseID: request.params.case_id, - subCaseId: request.query?.subCaseId, - logger, - }); - - const myComment = await caseService.getComment({ - client, - commentId: queryCommentId, - }); - - if (myComment == null) { - throw Boom.notFound(`This comment ${queryCommentId} does not exist anymore.`); - } - - if (myComment.attributes.type !== queryRestAttributes.type) { - throw Boom.badRequest(`You cannot change the type of the comment.`); - } - - const saveObjType = request.query?.subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; - - const caseRef = myComment.references.find((c) => c.type === saveObjType); - if (caseRef == null || (caseRef != null && caseRef.id !== commentableCase.id)) { - throw Boom.notFound( - `This comment ${queryCommentId} does not exist in ${commentableCase.id}).` - ); - } - - if (queryCommentVersion !== myComment.version) { - throw Boom.conflict( - 'This case has been updated. Please refresh before saving additional updates.' - ); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const userInfo: User = { - username, - full_name, - email, - }; - - const updatedDate = new Date().toISOString(); - const { - comment: updatedComment, - commentableCase: updatedCase, - } = await commentableCase.updateComment({ - updateRequest: query, - updatedAt: updatedDate, - user: userInfo, - }); - - await userActionService.postUserActions({ - client, - actions: [ - buildCommentUserActionItem({ - action: 'update', - actionAt: updatedDate, - actionBy: { username, full_name, email }, - caseId: request.params.case_id, - subCaseId: request.query?.subCaseId, - commentId: updatedComment.id, - fields: ['comment'], - newValue: JSON.stringify(queryRestAttributes), - oldValue: JSON.stringify( - // We are interested only in ContextBasicRt attributes - // myComment.attribute contains also CommentAttributesBasicRt attributes - pick(Object.keys(queryRestAttributes), myComment.attributes) - ), - }), - ], - }); - - return response.ok({ - body: await updatedCase.encode(), - }); - } catch (error) { - logger.error( - `Failed to patch comment in route case id: ${request.params.case_id} sub case id: ${request.query?.subCaseId}: ${error}` - ); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts deleted file mode 100644 index 27d5c47d47399..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { omit } from 'lodash/fp'; -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, - mockCaseComments, -} from '../../__fixtures__'; -import { initPostCommentApi } from './post_comment'; -import { CASE_COMMENTS_URL } from '../../../../../common'; -import { CommentType } from '../../../../../common'; - -describe('POST comment', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initPostCommentApi, 'post'); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), - })); - }); - - it(`Posts a new comment`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: { - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1].id).toEqual( - 'mock-comment' - ); - }); - - it(`Posts a new comment of type alert`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: { - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - rule: { - id: 'rule-id', - name: 'rule-name', - }, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1].id).toEqual( - 'mock-comment' - ); - }); - - it(`it throws when missing type`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - }); - - it(`it throws when missing attributes: type user`, async () => { - const allRequestAttributes = { - type: CommentType.user, - comment: 'a comment', - }; - - for (const attribute of ['comment']) { - const requestAttributes = omit(attribute, allRequestAttributes); - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: requestAttributes, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - } - }); - - it(`it throws when excess attributes are provided: type user`, async () => { - for (const attribute of ['alertId', 'index']) { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: { - [attribute]: attribute, - comment: 'a comment', - type: CommentType.user, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - } - }); - - it(`it throws when missing attributes: type alert`, async () => { - const allRequestAttributes = { - type: CommentType.alert, - index: 'test-index', - alertId: 'test-id', - }; - - for (const attribute of ['alertId', 'index']) { - const requestAttributes = omit(attribute, allRequestAttributes); - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: requestAttributes, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - } - }); - - it(`it throws when excess attributes are provided: type alert`, async () => { - for (const attribute of ['comment']) { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: { - [attribute]: attribute, - type: CommentType.alert, - index: 'test-index', - alertId: 'test-id', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - } - }); - - it(`Returns an error if the case does not exist`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'this-is-not-real', - }, - body: { - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(404); - expect(response.payload.isBoom).toEqual(true); - }); - - it(`Returns an error if postNewCase throws`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: { - comment: 'Throw an error', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - }); - - it(`Allow user to create comments without authentications`, async () => { - routeHandler = await createRoute(initPostCommentApi, 'post', true); - - const request = httpServerMock.createKibanaRequest({ - path: CASE_COMMENTS_URL, - method: 'post', - params: { - case_id: 'mock-id-1', - }, - body: { - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }), - true - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1]).toMatchInlineSnapshot(` - Object { - "associationType": "case", - "comment": "Wow, good luck catching that bad meanie!", - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "id": "mock-comment", - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", - } - `); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts deleted file mode 100644 index 626f53cdf4263..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCaseConfigure, - mockCaseMappings, -} from '../../__fixtures__'; - -import { initGetCaseConfigure } from './get_configure'; -import { CASE_CONFIGURE_URL, ConnectorTypes } from '../../../../../common'; -import { mappings } from '../../../../client/configure/mock'; -import { CasesClient } from '../../../../client'; - -describe('GET configuration', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initGetCaseConfigure, 'get'); - }); - - it('returns the configuration', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(200); - expect(res.payload).toEqual({ - ...mockCaseConfigure[0].attributes, - error: null, - mappings: mappings[ConnectorTypes.jira], - version: mockCaseConfigure[0].version, - }); - }); - - it('handles undefined version correctly', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: [{ ...mockCaseConfigure[0], version: undefined }], - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(200); - expect(res.payload).toEqual({ - connector: { - id: '789', - name: 'My connector 3', - type: '.jira', - fields: null, - }, - closure_type: 'close-by-user', - created_at: '2020-04-09T09:43:51.778Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - error: null, - mappings: mappings[ConnectorTypes.jira], - updated_at: '2020-04-09T09:43:51.778Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - version: '', - }); - }); - - it('returns an empty object when there is no configuration', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: [], - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(200); - - expect(res.payload).toEqual({}); - }); - - it('returns an error if find throws an error', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-find' }], - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(404); - expect(res.payload.isBoom).toEqual(true); - }); - - it('returns an error when mappings request throws', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: [], - }) - ); - const mockThrowContext = { - ...context, - cases: { - ...context.cases, - getCasesClient: () => - ({ - ...context?.cases?.getCasesClient(), - getMappings: () => { - throw new Error(); - }, - } as CasesClient), - }, - }; - - const res = await routeHandler(mockThrowContext, req, kibanaResponseFactory); - expect(res.status).toEqual(200); - expect(res.payload).toEqual({ - ...mockCaseConfigure[0].attributes, - error: 'Error connecting to My connector 3 instance', - mappings: [], - version: mockCaseConfigure[0].version, - }); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts deleted file mode 100644 index 03ac3dd8b13b3..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_configure.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { CaseConfigureResponseRt, ConnectorMappingsAttributes } from '../../../../../common'; -import { RouteDeps } from '../../types'; -import { wrapError } from '../../utils'; -import { CASE_CONFIGURE_URL } from '../../../../../common'; -import { transformESConnectorToCaseConnector } from '../helpers'; - -export function initGetCaseConfigure({ caseConfigureService, router, logger }: RouteDeps) { - router.get( - { - path: CASE_CONFIGURE_URL, - validate: false, - }, - async (context, request, response) => { - try { - let error = null; - const client = context.core.savedObjects.client; - - const myCaseConfigure = await caseConfigureService.find({ client }); - - const { connector, ...caseConfigureWithoutConnector } = myCaseConfigure.saved_objects[0] - ?.attributes ?? { connector: null }; - let mappings: ConnectorMappingsAttributes[] = []; - if (connector != null) { - if (!context.cases) { - throw Boom.badRequest('RouteHandlerContext is not registered for cases'); - } - const casesClient = context.cases.getCasesClient(); - const actionsClient = context.actions?.getActionsClient(); - if (actionsClient == null) { - throw Boom.notFound('Action client not found'); - } - try { - mappings = await casesClient.getMappings({ - actionsClient, - connectorId: connector.id, - connectorType: connector.type, - }); - } catch (e) { - error = e.isBoom - ? e.output.payload.message - : `Error connecting to ${connector.name} instance`; - } - } - - return response.ok({ - body: - myCaseConfigure.saved_objects.length > 0 - ? CaseConfigureResponseRt.encode({ - ...caseConfigureWithoutConnector, - connector: transformESConnectorToCaseConnector(connector), - mappings, - version: myCaseConfigure.saved_objects[0].version ?? '', - error, - }) - : {}, - }); - } catch (error) { - logger.error(`Failed to get case configure in route: ${error}`); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts deleted file mode 100644 index 082adf7b4803f..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCaseConfigure, - mockCaseMappings, -} from '../../__fixtures__'; - -import { initCaseConfigureGetActionConnector } from './get_connectors'; -import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../common'; -import { getActions } from '../../__mocks__/request_responses'; - -describe('GET connectors', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initCaseConfigureGetActionConnector, 'get'); - }); - - it('returns case owned connectors', async () => { - const req = httpServerMock.createKibanaRequest({ - path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(200); - - const expected = getActions(); - // The first connector returned by getActions is of type .webhook and we expect to be filtered - expected.shift(); - expect(res.payload).toEqual(expected); - }); - - it('filters out connectors that are not enabled in license', async () => { - const req = httpServerMock.createKibanaRequest({ - path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const actionsClient = context.actions.getActionsClient(); - (actionsClient.listTypes as jest.Mock).mockImplementation(() => - Promise.resolve([ - { - id: '.servicenow', - name: 'ServiceNow', - minimumLicenseRequired: 'platinum', - enabled: false, - enabledInConfig: true, - // User does not have a platinum license - enabledInLicense: false, - }, - { - id: '.jira', - name: 'Jira', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - { - id: '.resilient', - name: 'IBM Resilient', - minimumLicenseRequired: 'platinum', - enabled: false, - enabledInConfig: true, - // User does not have a platinum license - enabledInLicense: false, - }, - ]) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(200); - expect(res.payload).toEqual([ - { - id: '456', - actionTypeId: '.jira', - name: 'Connector without isCaseOwned', - config: { - apiUrl: 'https://elastic.jira.com', - }, - isPreconfigured: false, - referencedByCount: 0, - }, - { - id: 'for-mock-case-id-3', - actionTypeId: '.jira', - name: 'For mock case id 3', - config: { - apiUrl: 'https://elastic.jira.com', - }, - isPreconfigured: false, - referencedByCount: 0, - }, - ]); - }); - - it('it throws an error when actions client is null', async () => { - const req = httpServerMock.createKibanaRequest({ - path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - // @ts-expect-error - context.actions = undefined; - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(404); - expect(res.payload.isBoom).toEqual(true); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts deleted file mode 100644 index 7aec7e4f086b4..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/get_connectors.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { RouteDeps } from '../../types'; -import { wrapError } from '../../utils'; -import { ActionType } from '../../../../../../actions/common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { FindActionResult } from '../../../../../../actions/server/types'; - -import { CASE_CONFIGURE_CONNECTORS_URL, SUPPORTED_CONNECTORS } from '../../../../../common'; - -const isConnectorSupported = ( - action: FindActionResult, - actionTypes: Record -): boolean => - SUPPORTED_CONNECTORS.includes(action.actionTypeId) && - actionTypes[action.actionTypeId]?.enabledInLicense; - -/* - * Be aware that this api will only return 20 connectors - */ - -export function initCaseConfigureGetActionConnector({ router, logger }: RouteDeps) { - router.get( - { - path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, - validate: false, - }, - async (context, request, response) => { - try { - const actionsClient = context.actions?.getActionsClient(); - - if (actionsClient == null) { - throw Boom.notFound('Action client not found'); - } - - const actionTypes = (await actionsClient.listTypes()).reduce( - (types, type) => ({ ...types, [type.id]: type }), - {} - ); - - const results = (await actionsClient.getAll()).filter((action) => - isConnectorSupported(action, actionTypes) - ); - return response.ok({ body: results }); - } catch (error) { - logger.error(`Failed to get connectors in route: ${error}`); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts deleted file mode 100644 index c4e2b6af1cd6b..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCaseMappings, -} from '../../__fixtures__'; - -import { mockCaseConfigure } from '../../__fixtures__/mock_saved_objects'; -import { initPatchCaseConfigure } from './patch_configure'; -import { CASE_CONFIGURE_URL, ConnectorTypes } from '../../../../../common'; -import { CasesClient } from '../../../../client'; - -describe('PATCH configuration', () => { - let routeHandler: RequestHandler; - - beforeAll(async () => { - routeHandler = await createRoute(initPatchCaseConfigure, 'patch'); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2020-04-09T09:43:51.778Z'), - })); - }); - - it('patch configuration', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'patch', - body: { - closure_type: 'close-by-pushing', - version: mockCaseConfigure[0].version, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(res.payload).toEqual( - expect.objectContaining({ - ...mockCaseConfigure[0].attributes, - connector: { fields: null, id: '789', name: 'My connector 3', type: '.jira' }, - closure_type: 'close-by-pushing', - updated_at: '2020-04-09T09:43:51.778Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - }) - ); - }); - - it('patch configuration without authentication', async () => { - routeHandler = await createRoute(initPatchCaseConfigure, 'patch', true); - - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'patch', - body: { - closure_type: 'close-by-pushing', - version: mockCaseConfigure[0].version, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(res.payload).toEqual( - expect.objectContaining({ - ...mockCaseConfigure[0].attributes, - connector: { fields: null, id: '789', name: 'My connector 3', type: '.jira' }, - closure_type: 'close-by-pushing', - updated_at: '2020-04-09T09:43:51.778Z', - updated_by: { email: null, full_name: null, username: null }, - version: 'WzE3LDFd', - }) - ); - }); - - it('patch configuration - connector', async () => { - routeHandler = await createRoute(initPatchCaseConfigure, 'patch'); - - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'patch', - body: { - connector: { - id: 'connector-new', - name: 'New connector', - type: '.jira', - fields: null, - }, - version: mockCaseConfigure[0].version, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(res.payload).toEqual( - expect.objectContaining({ - ...mockCaseConfigure[0].attributes, - connector: { id: 'connector-new', name: 'New connector', type: '.jira', fields: null }, - closure_type: 'close-by-user', - updated_at: '2020-04-09T09:43:51.778Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - }) - ); - }); - - it('patch configuration with error message for getMappings throw', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'patch', - body: { - closure_type: 'close-by-pushing', - connector: { - id: 'connector-new', - name: 'New connector', - type: '.jira', - fields: null, - }, - version: mockCaseConfigure[0].version, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: [], - }) - ); - const mockThrowContext = { - ...context, - cases: { - ...context.cases, - getCasesClient: () => - ({ - ...context?.cases?.getCasesClient(), - getMappings: () => { - throw new Error(); - }, - } as CasesClient), - }, - }; - - const res = await routeHandler(mockThrowContext, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(res.payload).toEqual( - expect.objectContaining({ - mappings: [], - error: 'Error connecting to New connector instance', - }) - ); - }); - it('throw error when configuration have not being created', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'patch', - body: { - closure_type: 'close-by-pushing', - version: mockCaseConfigure[0].version, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: [], - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - - expect(res.status).toEqual(409); - expect(res.payload.isBoom).toEqual(true); - }); - - it('throw error when the versions are different', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'patch', - body: { - closure_type: 'close-by-pushing', - version: 'different-version', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - - expect(res.status).toEqual(409); - expect(res.payload.isBoom).toEqual(true); - }); - - it('handles undefined version correctly', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'patch', - body: { - connector: { - id: 'no-version', - name: 'no version', - type: ConnectorTypes.none, - fields: null, - }, - version: mockCaseConfigure[0].version, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.payload).toEqual( - expect.objectContaining({ - version: '', - }) - ); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts deleted file mode 100644 index 5fe38cf0efe48..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/patch_configure.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { - CasesConfigurePatchRt, - CaseConfigureResponseRt, - throwErrors, - ConnectorMappingsAttributes, -} from '../../../../../common'; -import { RouteDeps } from '../../types'; -import { wrapError, escapeHatch } from '../../utils'; -import { CASE_CONFIGURE_URL } from '../../../../../common'; -import { - transformCaseConnectorToEsConnector, - transformESConnectorToCaseConnector, -} from '../helpers'; - -export function initPatchCaseConfigure({ - caseConfigureService, - caseService, - router, - logger, -}: RouteDeps) { - router.patch( - { - path: CASE_CONFIGURE_URL, - validate: { - body: escapeHatch, - }, - }, - async (context, request, response) => { - try { - let error = null; - const client = context.core.savedObjects.client; - const query = pipe( - CasesConfigurePatchRt.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); - - const myCaseConfigure = await caseConfigureService.find({ client }); - const { version, connector, ...queryWithoutVersion } = query; - if (myCaseConfigure.saved_objects.length === 0) { - throw Boom.conflict( - 'You can not patch this configuration since you did not created first with a post.' - ); - } - - if (version !== myCaseConfigure.saved_objects[0].version) { - throw Boom.conflict( - 'This configuration has been updated. Please refresh before saving additional updates.' - ); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - - const updateDate = new Date().toISOString(); - - let mappings: ConnectorMappingsAttributes[] = []; - if (connector != null) { - if (!context.cases) { - throw Boom.badRequest('RouteHandlerContext is not registered for cases'); - } - const casesClient = context.cases.getCasesClient(); - const actionsClient = context.actions?.getActionsClient(); - if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); - } - try { - mappings = await casesClient.getMappings({ - actionsClient, - connectorId: connector.id, - connectorType: connector.type, - }); - } catch (e) { - error = e.isBoom - ? e.output.payload.message - : `Error connecting to ${connector.name} instance`; - } - } - const patch = await caseConfigureService.patch({ - client, - caseConfigureId: myCaseConfigure.saved_objects[0].id, - updatedAttributes: { - ...queryWithoutVersion, - ...(connector != null - ? { connector: transformCaseConnectorToEsConnector(connector) } - : {}), - updated_at: updateDate, - updated_by: { email, full_name, username }, - }, - }); - return response.ok({ - body: CaseConfigureResponseRt.encode({ - ...myCaseConfigure.saved_objects[0].attributes, - ...patch.attributes, - connector: transformESConnectorToCaseConnector( - patch.attributes.connector ?? myCaseConfigure.saved_objects[0].attributes.connector - ), - mappings, - version: patch.version ?? '', - error, - }), - }); - } catch (error) { - logger.error(`Failed to get patch configure in route: ${error}`); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts deleted file mode 100644 index 35b662078fe9c..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.test.ts +++ /dev/null @@ -1,472 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCaseConfigure, - mockCaseMappings, -} from '../../__fixtures__'; - -import { initPostCaseConfigure } from './post_configure'; -import { newConfiguration } from '../../__mocks__/request_responses'; -import { CASE_CONFIGURE_URL, ConnectorTypes } from '../../../../../common'; -import { CasesClient } from '../../../../client'; - -describe('POST configuration', () => { - let routeHandler: RequestHandler; - - beforeAll(async () => { - routeHandler = await createRoute(initPostCaseConfigure, 'post'); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2020-04-09T09:43:51.778Z'), - })); - }); - - it('create configuration', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: newConfiguration, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(res.payload).toEqual( - expect.objectContaining({ - connector: { - id: '456', - name: 'My connector 2', - type: '.jira', - fields: null, - }, - closure_type: 'close-by-pushing', - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - updated_at: null, - updated_by: null, - }) - ); - }); - it('create configuration with error message for getMappings throw', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: newConfiguration, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: [], - }) - ); - const mockThrowContext = { - ...context, - cases: { - ...context.cases, - getCasesClient: () => - ({ - ...context?.cases?.getCasesClient(), - getMappings: () => { - throw new Error(); - }, - } as CasesClient), - }, - }; - - const res = await routeHandler(mockThrowContext, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(res.payload).toEqual( - expect.objectContaining({ - mappings: [], - error: 'Error connecting to My connector 2 instance', - }) - ); - }); - - it('create configuration without authentication', async () => { - routeHandler = await createRoute(initPostCaseConfigure, 'post', true); - - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: newConfiguration, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(res.payload).toEqual( - expect.objectContaining({ - connector: { - id: '456', - name: 'My connector 2', - type: '.jira', - fields: null, - }, - closure_type: 'close-by-pushing', - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: null, full_name: null, username: null }, - updated_at: null, - updated_by: null, - }) - ); - }); - - it('throws when missing connector.id', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: { - connector: { - name: 'My connector 2', - type: '.jira', - fields: null, - }, - closure_type: 'close-by-pushing', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload.isBoom).toEqual(true); - }); - - it('throws when missing connector.name', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: { - connector: { - id: '456', - type: '.jira', - fields: null, - }, - closure_type: 'close-by-pushing', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload.isBoom).toEqual(true); - }); - - it('throws when missing connector.type', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: { - connector: { - id: '456', - name: 'My connector 2', - fields: null, - }, - closure_type: 'close-by-pushing', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload.isBoom).toEqual(true); - }); - - it('throws when missing connector.fields', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: { - connector: { - id: '456', - name: 'My connector 2', - type: ConnectorTypes.none, - }, - closure_type: 'close-by-pushing', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload.isBoom).toEqual(true); - }); - - it('throws when missing closure_type', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: { - connector: { - id: '456', - name: 'My connector 2', - type: '.jira', - fields: null, - }, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload.isBoom).toEqual(true); - }); - - it('it deletes the previous configuration', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: newConfiguration, - }); - - const savedObjectRepository = createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }); - - const { context } = await createRouteContext(savedObjectRepository); - - const res = await routeHandler(context, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(savedObjectRepository.delete.mock.calls[0][1]).toBe(mockCaseConfigure[0].id); - }); - - it('it does NOT delete when not found', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: newConfiguration, - }); - - const savedObjectRepository = createMockSavedObjectsRepository({ - caseConfigureSavedObject: [], - caseMappingsSavedObject: mockCaseMappings, - }); - - const { context } = await createRouteContext(savedObjectRepository); - - const res = await routeHandler(context, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(savedObjectRepository.delete).not.toHaveBeenCalled(); - }); - - it('it deletes all configuration', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: newConfiguration, - }); - - const savedObjectRepository = createMockSavedObjectsRepository({ - caseConfigureSavedObject: [ - mockCaseConfigure[0], - { ...mockCaseConfigure[0], id: 'mock-configuration-2' }, - ], - caseMappingsSavedObject: mockCaseMappings, - }); - - const { context } = await createRouteContext(savedObjectRepository); - - const res = await routeHandler(context, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(savedObjectRepository.delete.mock.calls[0][1]).toBe(mockCaseConfigure[0].id); - expect(savedObjectRepository.delete.mock.calls[1][1]).toBe('mock-configuration-2'); - }); - - it('returns an error if find throws an error', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: newConfiguration, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-find' }], - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(404); - expect(res.payload.isBoom).toEqual(true); - }); - - it('returns an error if delete throws an error', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: newConfiguration, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-delete' }], - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(500); - expect(res.payload.isBoom).toEqual(true); - }); - - it('returns an error if post throws an error', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: { - connector: { - id: 'throw-error-create', - name: 'My connector 2', - fields: null, - }, - closure_type: 'close-by-pushing', - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload.isBoom).toEqual(true); - }); - - it('handles undefined version correctly', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: { - ...newConfiguration, - connector: { - id: 'no-version', - name: 'no version', - type: ConnectorTypes.none, - fields: null, - }, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(200); - expect(res.payload).toEqual( - expect.objectContaining({ - version: '', - }) - ); - }); - - it('returns an error if fields are not null', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: { - ...newConfiguration, - connector: { id: 'not-null', name: 'not-null', type: ConnectorTypes.none, fields: {} }, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload.isBoom).toEqual(true); - }); - - it('returns an error if the type of the connector does not exists', async () => { - const req = httpServerMock.createKibanaRequest({ - path: CASE_CONFIGURE_URL, - method: 'post', - body: { - ...newConfiguration, - connector: { id: 'not-exists', name: 'not-exist', type: '.not-exists', fields: null }, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseConfigureSavedObject: mockCaseConfigure, - caseMappingsSavedObject: mockCaseMappings, - }) - ); - - const res = await routeHandler(context, req, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload.isBoom).toEqual(true); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts deleted file mode 100644 index 74ad02f47e178..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/configure/post_configure.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { - CasesConfigureRequestRt, - CaseConfigureResponseRt, - throwErrors, - ConnectorMappingsAttributes, -} from '../../../../../common'; -import { RouteDeps } from '../../types'; -import { wrapError, escapeHatch } from '../../utils'; -import { CASE_CONFIGURE_URL } from '../../../../../common'; -import { - transformCaseConnectorToEsConnector, - transformESConnectorToCaseConnector, -} from '../helpers'; - -export function initPostCaseConfigure({ - caseConfigureService, - caseService, - router, - logger, -}: RouteDeps) { - router.post( - { - path: CASE_CONFIGURE_URL, - validate: { - body: escapeHatch, - }, - }, - async (context, request, response) => { - try { - let error = null; - if (!context.cases) { - throw Boom.badRequest('RouteHandlerContext is not registered for cases'); - } - const casesClient = context.cases.getCasesClient(); - const actionsClient = context.actions?.getActionsClient(); - if (actionsClient == null) { - throw Boom.notFound('Action client not found'); - } - const client = context.core.savedObjects.client; - const query = pipe( - CasesConfigureRequestRt.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); - - const myCaseConfigure = await caseConfigureService.find({ client }); - if (myCaseConfigure.saved_objects.length > 0) { - await Promise.all( - myCaseConfigure.saved_objects.map((cc) => - caseConfigureService.delete({ client, caseConfigureId: cc.id }) - ) - ); - } - // eslint-disable-next-line @typescript-eslint/naming-convention - const { email, full_name, username } = await caseService.getUser({ request }); - - const creationDate = new Date().toISOString(); - let mappings: ConnectorMappingsAttributes[] = []; - try { - mappings = await casesClient.getMappings({ - actionsClient, - connectorId: query.connector.id, - connectorType: query.connector.type, - }); - } catch (e) { - error = e.isBoom - ? e.output.payload.message - : `Error connecting to ${query.connector.name} instance`; - } - const post = await caseConfigureService.post({ - client, - attributes: { - ...query, - connector: transformCaseConnectorToEsConnector(query.connector), - created_at: creationDate, - created_by: { email, full_name, username }, - updated_at: null, - updated_by: null, - }, - }); - - return response.ok({ - body: CaseConfigureResponseRt.encode({ - ...post.attributes, - // Reserve for future implementations - connector: transformESConnectorToCaseConnector(post.attributes.connector), - mappings, - version: post.version ?? '', - error, - }), - }); - } catch (error) { - logger.error(`Failed to post case configure in route: ${error}`); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts deleted file mode 100644 index 7748a079ceb4d..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, - mockCasesErrorTriggerData, - mockCaseComments, -} from '../__fixtures__'; -import { initDeleteCasesApi } from './delete_cases'; -import { CASES_URL } from '../../../../common'; - -describe('DELETE case', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initDeleteCasesApi, 'delete'); - }); - it(`deletes the case. responds with 204`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'delete', - query: { - ids: ['mock-id-1'], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(204); - }); - it(`returns an error when thrown from deleteCase service`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'delete', - query: { - ids: ['not-real'], - }, - }); - - const mockSO = createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }); - // Adding this because the delete API needs to get all the cases first to determine if they are removable or not - // so it makes a call to bulkGet first - mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); - - const { context } = await createRouteContext(mockSO); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(404); - }); - it(`returns an error when thrown from getAllCaseComments service`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'delete', - query: { - ids: ['bad-guy'], - }, - }); - - const mockSO = createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCaseComments, - }); - - // Adding this because the delete API needs to get all the cases first to determine if they are removable or not - // so it makes a call to bulkGet first - mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); - - const { context } = await createRouteContext(mockSO); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - }); - it(`returns an error when thrown from deleteComment service`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'delete', - query: { - ids: ['valid-id'], - }, - }); - - const mockSO = createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCasesErrorTriggerData, - }); - - // Adding this because the delete API needs to get all the cases first to determine if they are removable or not - // so it makes a call to bulkGet first - mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); - - const { context } = await createRouteContext(mockSO); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts index d0cfc03e69f7c..1784a434292cc 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/delete_cases.ts @@ -7,46 +7,11 @@ import { schema } from '@kbn/config-schema'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -import { CASES_URL, ENABLE_CASE_CONNECTOR } from '../../../../common'; -import { CaseServiceSetup } from '../../../services'; +import { CASES_URL } from '../../../../common/constants'; -async function deleteSubCases({ - caseService, - client, - caseIds, -}: { - caseService: CaseServiceSetup; - client: SavedObjectsClientContract; - caseIds: string[]; -}) { - const subCasesForCaseIds = await caseService.findSubCasesByCaseId({ client, ids: caseIds }); - - const subCaseIDs = subCasesForCaseIds.saved_objects.map((subCase) => subCase.id); - const commentsForSubCases = await caseService.getAllSubCaseComments({ - client, - id: subCaseIDs, - }); - - // This shouldn't actually delete anything because all the comments should be deleted when comments are deleted - // per case ID - await Promise.all( - commentsForSubCases.saved_objects.map((commentSO) => - caseService.deleteComment({ client, commentId: commentSO.id }) - ) - ); - - await Promise.all( - subCasesForCaseIds.saved_objects.map((subCaseSO) => - caseService.deleteSubCase(client, subCaseSO.id) - ) - ); -} - -export function initDeleteCasesApi({ caseService, router, userActionService, logger }: RouteDeps) { +export function initDeleteCasesApi({ router, logger }: RouteDeps) { router.delete( { path: CASES_URL, @@ -58,66 +23,8 @@ export function initDeleteCasesApi({ caseService, router, userActionService, log }, async (context, request, response) => { try { - const client = context.core.savedObjects.client; - await Promise.all( - request.query.ids.map((id) => - caseService.deleteCase({ - client, - id, - }) - ) - ); - const comments = await Promise.all( - request.query.ids.map((id) => - caseService.getAllCaseComments({ - client, - id, - }) - ) - ); - - if (comments.some((c) => c.saved_objects.length > 0)) { - await Promise.all( - comments.map((c) => - Promise.all( - c.saved_objects.map(({ id }) => - caseService.deleteComment({ - client, - commentId: id, - }) - ) - ) - ) - ); - } - - if (ENABLE_CASE_CONNECTOR) { - await deleteSubCases({ caseService, client, caseIds: request.query.ids }); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const deleteDate = new Date().toISOString(); - - await userActionService.postUserActions({ - client, - actions: request.query.ids.map((id) => - buildCaseUserActionItem({ - action: 'create', - actionAt: deleteDate, - actionBy: { username, full_name, email }, - caseId: id, - fields: [ - 'comment', - 'description', - 'status', - 'tags', - 'title', - ...(ENABLE_CASE_CONNECTOR ? ['sub_case'] : []), - ], - }) - ), - }); + const client = await context.cases.getCasesClient(); + await client.cases.delete(request.query.ids); return response.noContent(); } catch (error) { diff --git a/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts deleted file mode 100644 index 75586896390fc..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/find_cases.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, -} from '../__fixtures__'; -import { initFindCasesApi } from './find_cases'; -import { CASES_URL } from '../../../../common'; -import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; - -describe('FIND all cases', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initFindCasesApi, 'get'); - }); - - it(`gets all the cases`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: `${CASES_URL}/_find`, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.cases).toHaveLength(4); - // mockSavedObjectsRepository do not support filters and returns all cases every time. - expect(response.payload.count_open_cases).toEqual(4); - expect(response.payload.count_closed_cases).toEqual(4); - expect(response.payload.count_in_progress_cases).toEqual(4); - }); - - it(`has proper connector id on cases with configured connector`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: `${CASES_URL}/_find`, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.cases[2].connector.id).toEqual('123'); - }); - - it(`adds 'none' connector id to cases without when 3rd party unconfigured`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: `${CASES_URL}/_find`, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: [mockCaseNoConnectorId], - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.cases[0].connector.id).toEqual('none'); - }); - - it(`adds default connector id to cases without when 3rd party configured`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: `${CASES_URL}/_find`, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: [mockCaseNoConnectorId], - caseConfigureSavedObject: mockCaseConfigure, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.cases[0].connector.id).toEqual('none'); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts index 77b1d6b23f912..1ded265a8b176 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/find_cases.ts @@ -5,24 +5,12 @@ * 2.0. */ -import Boom from '@hapi/boom'; - -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { - CasesFindResponseRt, - CasesFindRequestRt, - throwErrors, - caseStatuses, -} from '../../../../common'; -import { transformCases, wrapError, escapeHatch } from '../utils'; +import { CasesFindRequest } from '../../../../common/api'; +import { wrapError, escapeHatch } from '../utils'; import { RouteDeps } from '../types'; -import { CASES_URL } from '../../../../common'; -import { constructQueryOptions } from './helpers'; +import { CASES_URL } from '../../../../common/constants'; -export function initFindCasesApi({ caseService, router, logger }: RouteDeps) { +export function initFindCasesApi({ router, logger }: RouteDeps) { router.get( { path: `${CASES_URL}/_find`, @@ -32,49 +20,14 @@ export function initFindCasesApi({ caseService, router, logger }: RouteDeps) { }, async (context, request, response) => { try { - const client = context.core.savedObjects.client; - const queryParams = pipe( - CasesFindRequestRt.decode(request.query), - fold(throwErrors(Boom.badRequest), identity) - ); - const queryArgs = { - tags: queryParams.tags, - reporters: queryParams.reporters, - sortByField: queryParams.sortField, - status: queryParams.status, - caseType: queryParams.type, - }; - - const caseQueries = constructQueryOptions(queryArgs); - const cases = await caseService.findCasesGroupedByID({ - client, - caseOptions: { ...queryParams, ...caseQueries.case }, - subCaseOptions: caseQueries.subCase, - }); - - const [openCases, inProgressCases, closedCases] = await Promise.all([ - ...caseStatuses.map((status) => { - const statusQuery = constructQueryOptions({ ...queryArgs, status }); - return caseService.findCaseStatusStats({ - client, - caseOptions: statusQuery.case, - subCaseOptions: statusQuery.subCase, - }); - }), - ]); + if (!context.cases) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } + const casesClient = await context.cases.getCasesClient(); + const options = request.query as CasesFindRequest; return response.ok({ - body: CasesFindResponseRt.encode( - transformCases({ - casesMap: cases.casesMap, - page: cases.page, - perPage: cases.perPage, - total: cases.total, - countOpenCases: openCases, - countInProgressCases: inProgressCases, - countClosedCases: closedCases, - }) - ), + body: await casesClient.cases.find({ ...options }), }); } catch (error) { logger.error(`Failed to find cases in route: ${error}`); diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts deleted file mode 100644 index 768bbca62f3fe..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/get_case.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler, SavedObject } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { ConnectorTypes, ESCaseAttributes } from '../../../../common'; -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, - mockCasesErrorTriggerData, - mockCaseComments, - mockCaseNoConnectorId, - mockCaseConfigure, -} from '../__fixtures__'; -import { flattenCaseSavedObject } from '../utils'; -import { initGetCaseApi } from './get_case'; -import { CASE_DETAILS_URL } from '../../../../common'; - -describe('GET case', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initGetCaseApi, 'get'); - }); - it(`returns the case with empty case comments when includeComments is false`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_DETAILS_URL, - method: 'get', - params: { - case_id: 'mock-id-1', - }, - query: { - includeComments: false, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - const savedObject = (mockCases.find( - (s) => s.id === 'mock-id-1' - ) as unknown) as SavedObject; - expect(response.status).toEqual(200); - expect(response.payload).toEqual( - flattenCaseSavedObject({ - savedObject, - }) - ); - expect(response.payload.comments).toEqual([]); - }); - - it(`returns an error when thrown from getCase`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_DETAILS_URL, - method: 'get', - params: { - case_id: 'abcdefg', - }, - query: { - includeComments: false, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - - expect(response.status).toEqual(404); - expect(response.payload.isBoom).toEqual(true); - }); - - it(`returns the case with case comments when includeComments is true`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_DETAILS_URL, - method: 'get', - params: { - case_id: 'mock-id-1', - }, - query: { - includeComments: true, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - - expect(response.status).toEqual(200); - expect(response.payload.comments).toHaveLength(6); - }); - - it(`returns an error when thrown from getAllCaseComments`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_DETAILS_URL, - method: 'get', - params: { - case_id: 'bad-guy', - }, - query: { - includeComments: true, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - - expect(response.status).toEqual(400); - }); - - it(`case w/o connector.id - returns the case with connector id when 3rd party unconfigured`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_DETAILS_URL, - method: 'get', - params: { - case_id: 'mock-no-connector_id', - }, - query: { - includeComments: false, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: [mockCaseNoConnectorId], - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - - expect(response.status).toEqual(200); - expect(response.payload.connector).toEqual({ - fields: null, - id: 'none', - name: 'none', - type: ConnectorTypes.none, - }); - }); - - it(`case w/o connector.id - returns the case with connector id when 3rd party configured`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_DETAILS_URL, - method: 'get', - params: { - case_id: 'mock-no-connector_id', - }, - query: { - includeComments: false, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: [mockCaseNoConnectorId], - caseConfigureSavedObject: mockCaseConfigure, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - - expect(response.status).toEqual(200); - expect(response.payload.connector).toEqual({ - fields: null, - id: 'none', - name: 'none', - type: '.none', - }); - }); - - it(`case w/ connector.id - returns the case with connector id when case already has connectorId`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_DETAILS_URL, - method: 'get', - params: { - case_id: 'mock-id-3', - }, - query: { - includeComments: false, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseConfigureSavedObject: mockCaseConfigure, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - - expect(response.status).toEqual(200); - expect(response.payload.connector).toEqual({ - fields: { issueType: 'Task', priority: 'High', parent: null }, - id: '123', - name: 'My connector', - type: '.jira', - }); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts index c69eae7fb1f94..9d26fbb90328c 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/get_case.ts @@ -7,10 +7,9 @@ import { schema } from '@kbn/config-schema'; -import Boom from '@hapi/boom'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -import { CASE_DETAILS_URL, ENABLE_CASE_CONNECTOR } from '../../../../common'; +import { CASE_DETAILS_URL } from '../../../../common/constants'; export function initGetCaseApi({ router, logger }: RouteDeps) { router.get( @@ -28,16 +27,11 @@ export function initGetCaseApi({ router, logger }: RouteDeps) { }, async (context, request, response) => { try { - if (!ENABLE_CASE_CONNECTOR && request.query.includeSubCaseComments !== undefined) { - throw Boom.badRequest( - 'The `subCaseId` is not supported when the case connector feature is disabled' - ); - } - const casesClient = context.cases.getCasesClient(); + const casesClient = await context.cases.getCasesClient(); const id = request.params.case_id; return response.ok({ - body: await casesClient.get({ + body: await casesClient.cases.get({ id, includeComments: request.query.includeComments, includeSubCaseComments: request.query.includeSubCaseComments, diff --git a/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts b/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts deleted file mode 100644 index a1d25aa295799..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/helpers.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { SavedObjectsFindResponse } from 'kibana/server'; -import { - CaseConnector, - ConnectorTypes, - ESCaseConnector, - ESCasesConfigureAttributes, -} from '../../../../common'; -import { mockCaseConfigure } from '../__fixtures__'; -import { - transformCaseConnectorToEsConnector, - transformESConnectorToCaseConnector, - getConnectorFromConfiguration, -} from './helpers'; - -describe('helpers', () => { - const caseConnector: CaseConnector = { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }; - - const esCaseConnector: ESCaseConnector = { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], - }; - - const caseConfigure: SavedObjectsFindResponse = { - saved_objects: [{ ...mockCaseConfigure[0], score: 0 }], - total: 1, - per_page: 20, - page: 1, - }; - - describe('transformCaseConnectorToEsConnector', () => { - it('transform correctly', () => { - expect(transformCaseConnectorToEsConnector(caseConnector)).toEqual(esCaseConnector); - }); - - it('transform correctly with null attributes', () => { - // @ts-ignore this is case the connector does not exist for old cases object or configurations - expect(transformCaseConnectorToEsConnector(null)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: [], - }); - }); - }); - - describe('transformESConnectorToCaseConnector', () => { - it('transform correctly', () => { - expect(transformESConnectorToCaseConnector(esCaseConnector)).toEqual(caseConnector); - }); - - it('transform correctly with null attributes', () => { - // @ts-ignore this is case the connector does not exist for old cases object or configurations - expect(transformESConnectorToCaseConnector(null)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }); - }); - }); - - describe('getConnectorFromConfiguration', () => { - it('transform correctly', () => { - expect(getConnectorFromConfiguration(caseConfigure)).toEqual({ - id: '789', - name: 'My connector 3', - type: ConnectorTypes.jira, - fields: null, - }); - }); - - it('transform correctly with no connector', () => { - const caseConfigureNoConnector: SavedObjectsFindResponse = { - ...caseConfigure, - saved_objects: [ - { - ...mockCaseConfigure[0], - // @ts-ignore this is case the connector does not exist for old cases object or configurations - attributes: { ...mockCaseConfigure[0].attributes, connector: null }, - score: 0, - }, - ], - }; - - expect(getConnectorFromConfiguration(caseConfigureNoConnector)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }); - }); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/helpers.ts b/x-pack/plugins/cases/server/routes/api/cases/helpers.ts deleted file mode 100644 index 5f51c9b1f8d8c..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/helpers.ts +++ /dev/null @@ -1,337 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { get, isPlainObject } from 'lodash'; -import deepEqual from 'fast-deep-equal'; - -import { SavedObjectsFindResponse } from 'kibana/server'; -import { - CaseConnector, - CaseStatuses, - CaseType, - ConnectorTypeFields, - ConnectorTypes, - ESCaseConnector, - ESCasesConfigureAttributes, - ESConnectorFields, - SavedObjectFindOptions, -} from '../../../../common'; -import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; -import { sortToSnake } from '../utils'; -import { combineFilters } from '../../../common'; - -export const addStatusFilter = ({ - status, - appendFilter, - type = CASE_SAVED_OBJECT, -}: { - status?: CaseStatuses; - appendFilter?: string; - type?: string; -}) => { - const filters: string[] = []; - if (status) { - filters.push(`${type}.attributes.status: ${status}`); - } - - if (appendFilter) { - filters.push(appendFilter); - } - return combineFilters(filters, 'AND'); -}; - -export const buildFilter = ({ - filters, - field, - operator, - type = CASE_SAVED_OBJECT, -}: { - filters: string | string[] | undefined; - field: string; - operator: 'OR' | 'AND'; - type?: string; -}): string => { - // if it is an empty string, empty array of strings, or undefined just return - if (!filters || filters.length <= 0) { - return ''; - } - - const arrayFilters = !Array.isArray(filters) ? [filters] : filters; - - return combineFilters( - arrayFilters.map((filter) => `${type}.attributes.${field}: ${filter}`), - operator - ); -}; - -/** - * Constructs the filters used for finding cases and sub cases. - * There are a few scenarios that this function tries to handle when constructing the filters used for finding cases - * and sub cases. - * - * Scenario 1: - * Type == Individual - * If the API request specifies that it wants only individual cases (aka not collections) then we need to add that - * specific filter when call the saved objects find api. This will filter out any collection cases. - * - * Scenario 2: - * Type == collection - * If the API request specifies that it only wants collection cases (cases that have sub cases) then we need to add - * the filter for collections AND we need to ignore any status filter for the case find call. This is because a - * collection's status is no longer relevant when it has sub cases. The user cannot change the status for a collection - * only for its sub cases. The status filter will be applied to the find request when looking for sub cases. - * - * Scenario 3: - * No Type is specified - * If the API request does not want to filter on type but instead get both collections and regular individual cases then - * we need to find all cases that match the other filter criteria and sub cases. To do this we construct the following query: - * - * ((status == some_status and type === individual) or type == collection) and (tags == blah) and (reporter == yo) - * This forces us to honor the status request for individual cases but gets us ALL collection cases that match the other - * filter criteria. When we search for sub cases we will use that status filter in that find call as well. - */ -export const constructQueryOptions = ({ - tags, - reporters, - status, - sortByField, - caseType, -}: { - tags?: string | string[]; - reporters?: string | string[]; - status?: CaseStatuses; - sortByField?: string; - caseType?: CaseType; -}): { case: SavedObjectFindOptions; subCase?: SavedObjectFindOptions } => { - const tagsFilter = buildFilter({ filters: tags, field: 'tags', operator: 'OR' }); - const reportersFilter = buildFilter({ - filters: reporters, - field: 'created_by.username', - operator: 'OR', - }); - const sortField = sortToSnake(sortByField); - - switch (caseType) { - case CaseType.individual: { - // The cases filter will result in this structure "status === oh and (type === individual) and (tags === blah) and (reporter === yo)" - // The subCase filter will be undefined because we don't need to find sub cases if type === individual - - // We do not want to support multiple type's being used, so force it to be a single filter value - const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; - const caseFilters = addStatusFilter({ - status, - appendFilter: combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'), - }); - return { - case: { - filter: caseFilters, - sortField, - }, - }; - } - case CaseType.collection: { - // The cases filter will result in this structure "(type == parent) and (tags == blah) and (reporter == yo)" - // The sub case filter will use the query.status if it exists - const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.collection}`; - const caseFilters = combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'); - - return { - case: { - filter: caseFilters, - sortField, - }, - subCase: { - filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), - sortField, - }, - }; - } - default: { - /** - * In this scenario no type filter was sent, so we want to honor the status filter if one exists. - * To construct the filter and honor the status portion we need to find all individual cases that - * have that particular status. We also need to find cases that have sub cases but we want to ignore the - * case collection's status because it is not relevant. We only care about the status of the sub cases if the - * case is a collection. - * - * The cases filter will result in this structure "((status == open and type === individual) or type == parent) and (tags == blah) and (reporter == yo)" - * The sub case filter will use the query.status if it exists - */ - const typeIndividual = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; - const typeParent = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.collection}`; - - const statusFilter = combineFilters([addStatusFilter({ status }), typeIndividual], 'AND'); - const statusAndType = combineFilters([statusFilter, typeParent], 'OR'); - const caseFilters = combineFilters([statusAndType, tagsFilter, reportersFilter], 'AND'); - - return { - case: { - filter: caseFilters, - sortField, - }, - subCase: { - filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), - sortField, - }, - }; - } - } -}; - -interface CompareArrays { - addedItems: string[]; - deletedItems: string[]; -} -export const compareArrays = ({ - originalValue, - updatedValue, -}: { - originalValue: string[]; - updatedValue: string[]; -}): CompareArrays => { - const result: CompareArrays = { - addedItems: [], - deletedItems: [], - }; - originalValue.forEach((origVal) => { - if (!updatedValue.includes(origVal)) { - result.deletedItems = [...result.deletedItems, origVal]; - } - }); - updatedValue.forEach((updatedVal) => { - if (!originalValue.includes(updatedVal)) { - result.addedItems = [...result.addedItems, updatedVal]; - } - }); - - return result; -}; - -export const isTwoArraysDifference = ( - originalValue: unknown, - updatedValue: unknown -): CompareArrays | null => { - if ( - originalValue != null && - updatedValue != null && - Array.isArray(updatedValue) && - Array.isArray(originalValue) - ) { - const compObj = compareArrays({ originalValue, updatedValue }); - if (compObj.addedItems.length > 0 || compObj.deletedItems.length > 0) { - return compObj; - } - } - return null; -}; - -interface CaseWithIDVersion { - id: string; - version: string; - [key: string]: unknown; -} - -export const getCaseToUpdate = ( - currentCase: unknown, - queryCase: CaseWithIDVersion -): CaseWithIDVersion => - Object.entries(queryCase).reduce( - (acc, [key, value]) => { - const currentValue = get(currentCase, key); - if (Array.isArray(currentValue) && Array.isArray(value)) { - if (isTwoArraysDifference(value, currentValue)) { - return { - ...acc, - [key]: value, - }; - } - return acc; - } else if (isPlainObject(currentValue) && isPlainObject(value)) { - if (!deepEqual(currentValue, value)) { - return { - ...acc, - [key]: value, - }; - } - - return acc; - } else if (currentValue != null && value !== currentValue) { - return { - ...acc, - [key]: value, - }; - } - return acc; - }, - { id: queryCase.id, version: queryCase.version } - ); - -export const getNoneCaseConnector = () => ({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, -}); - -export const getConnectorFromConfiguration = ( - caseConfigure: SavedObjectsFindResponse -): CaseConnector => { - let caseConnector = getNoneCaseConnector(); - if ( - caseConfigure.saved_objects.length > 0 && - caseConfigure.saved_objects[0].attributes.connector - ) { - caseConnector = { - id: caseConfigure.saved_objects[0].attributes.connector.id, - name: caseConfigure.saved_objects[0].attributes.connector.name, - type: caseConfigure.saved_objects[0].attributes.connector.type, - fields: null, - }; - } - return caseConnector; -}; - -export const transformCaseConnectorToEsConnector = (connector: CaseConnector): ESCaseConnector => ({ - id: connector?.id ?? 'none', - name: connector?.name ?? 'none', - type: connector?.type ?? '.none', - fields: - connector?.fields != null - ? Object.entries(connector.fields).reduce( - (acc, [key, value]) => [ - ...acc, - { - key, - value, - }, - ], - [] - ) - : [], -}); - -export const transformESConnectorToCaseConnector = (connector?: ESCaseConnector): CaseConnector => { - const connectorTypeField = { - type: connector?.type ?? '.none', - fields: - connector && connector.fields != null && connector.fields.length > 0 - ? connector.fields.reduce( - (fields, { key, value }) => ({ - ...fields, - [key]: value, - }), - {} - ) - : null, - } as ConnectorTypeFields; - - return { - id: connector?.id ?? 'none', - name: connector?.name ?? 'none', - ...connectorTypeField, - }; -}; diff --git a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts deleted file mode 100644 index 96a891441ea5f..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, - mockCaseComments, -} from '../__fixtures__'; -import { initPatchCasesApi } from './patch_cases'; -import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -import { CaseStatuses } from '../../../../common'; - -describe('PATCH cases', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initPatchCasesApi, 'patch'); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), - })); - }); - - it(`Close a case`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/cases', - method: 'patch', - body: { - cases: [ - { - id: 'mock-id-1', - status: CaseStatuses.closed, - version: 'WzAsMV0=', - }, - ], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": "2019-11-25T21:54:48.952Z", - "closed_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "closed", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, - ] - `); - }); - - it(`Open a case`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/cases', - method: 'patch', - body: { - cases: [ - { - id: 'mock-id-4', - status: CaseStatuses.open, - version: 'WzUsMV0=', - }, - ], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseConfigureSavedObject: mockCaseConfigure, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-4", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, - ] - `); - }); - - it(`Change case to in-progress`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/cases', - method: 'patch', - body: { - cases: [ - { - id: 'mock-id-1', - status: CaseStatuses['in-progress'], - version: 'WzAsMV0=', - }, - ], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload).toMatchInlineSnapshot(` - Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "in-progress", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "d00d@awesome.com", - "full_name": "Awesome D00d", - "username": "awesome", - }, - "version": "WzE3LDFd", - }, - ] - `); - }); - - it(`Patches a case without a connector.id`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/cases', - method: 'patch', - body: { - cases: [ - { - id: 'mock-no-connector_id', - status: CaseStatuses.closed, - version: 'WzAsMV0=', - }, - ], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: [mockCaseNoConnectorId], - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload[0].connector.id).toEqual('none'); - }); - - it(`Patches a case with a connector.id`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/cases', - method: 'patch', - body: { - cases: [ - { - id: 'mock-id-3', - status: CaseStatuses.closed, - version: 'WzUsMV0=', - }, - ], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload[0].connector.id).toEqual('123'); - }); - - it(`Change connector`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/cases', - method: 'patch', - body: { - cases: [ - { - id: 'mock-id-3', - connector: { - id: '456', - name: 'My connector 2', - type: '.jira', - fields: { issueType: 'Bug', priority: 'Low', parent: null }, - }, - version: 'WzUsMV0=', - }, - ], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload[0].connector).toEqual({ - id: '456', - name: 'My connector 2', - type: '.jira', - fields: { issueType: 'Bug', priority: 'Low', parent: null }, - }); - }); - - it(`Fails with 409 if version does not match`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/cases', - method: 'patch', - body: { - cases: [ - { - id: 'mock-id-1', - case: { status: CaseStatuses.closed }, - version: 'badv=', - }, - ], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(409); - }); - - it(`Fails with 406 if updated field is unchanged`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/cases', - method: 'patch', - body: { - cases: [ - { - id: 'mock-id-1', - case: { status: CaseStatuses.open }, - version: 'WzAsMV0=', - }, - ], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(406); - }); - - it(`Returns an error if updateCase throws`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: '/api/cases', - method: 'patch', - body: { - cases: [ - { - id: 'mock-id-does-not-exist', - status: CaseStatuses.closed, - version: 'WzAsMV0=', - }, - ], - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(404); - expect(response.payload.isBoom).toEqual(true); - }); -}); 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 092f88c1a8a20..8c72dee719d05 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 @@ -24,11 +24,11 @@ export function initPatchCasesApi({ router, logger }: RouteDeps) { return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); } - const casesClient = context.cases.getCasesClient(); + const casesClient = await context.cases.getCasesClient(); const cases = request.body as CasesPatchRequest; return response.ok({ - body: await casesClient.update(cases), + body: await casesClient.cases.update(cases), }); } catch (error) { logger.error(`Failed to patch cases in route: ${error}`); diff --git a/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts deleted file mode 100644 index 669d3a5e58874..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/post_case.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, -} from '../__fixtures__'; -import { initPostCaseApi } from './post_case'; -import { CASES_URL } from '../../../../common'; -import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects'; -import { ConnectorTypes, CaseStatuses } from '../../../../common'; - -describe('POST cases', () => { - let routeHandler: RequestHandler; - beforeAll(async () => { - routeHandler = await createRoute(initPostCaseApi, 'post'); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2019-11-25T21:54:48.952Z'), - })); - }); - - it(`Posts a new case, no connector configured`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'post', - body: { - description: 'This is a brand new case of a bad meanie defacing data', - title: 'Super Bad Security Issue', - tags: ['defacement'], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - settings: { - syncAlerts: true, - }, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.id).toEqual('mock-it'); - expect(response.payload.status).toEqual('open'); - expect(response.payload.created_by.username).toEqual('awesome'); - expect(response.payload.connector).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }); - }); - - it(`Posts a new case, connector provided`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'post', - body: { - description: 'This is a brand new case of a bad meanie defacing data', - title: 'Super Bad Security Issue', - tags: ['defacement'], - connector: { - id: '123', - name: 'Jira', - type: '.jira', - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - settings: { - syncAlerts: true, - }, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseConfigureSavedObject: mockCaseConfigure, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.connector).toEqual({ - id: '123', - name: 'Jira', - type: '.jira', - fields: { issueType: 'Task', priority: 'High', parent: null }, - }); - }); - - it(`Error if you passing status for a new case`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'post', - body: { - description: 'This is a brand new case of a bad meanie defacing data', - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - connector: null, - settings: { - syncAlerts: true, - }, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - }); - - it(`Returns an error if postNewCase throws`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'post', - body: { - description: 'Throw an error', - title: 'Super Bad Security Issue', - tags: ['error'], - connector: null, - settings: { - syncAlerts: true, - }, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); - }); - - it(`Allow user to create case without authentication`, async () => { - routeHandler = await createRoute(initPostCaseApi, 'post', true); - - const request = httpServerMock.createKibanaRequest({ - path: CASES_URL, - method: 'post', - body: { - description: 'This is a brand new case of a bad meanie defacing data', - title: 'Super Bad Security Issue', - tags: ['defacement'], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - settings: { - syncAlerts: true, - }, - }, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseConfigureSavedObject: mockCaseConfigure, - }), - true - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-it", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 0, - "type": "individual", - "updated_at": null, - "updated_by": null, - "version": "WzksMV0=", - } - `); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/post_case.ts b/x-pack/plugins/cases/server/routes/api/cases/post_case.ts index a7951a1a71344..cc5d2c98333c8 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/post_case.ts @@ -24,11 +24,11 @@ export function initPostCaseApi({ router, logger }: RouteDeps) { if (!context.cases) { return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); } - const casesClient = context.cases.getCasesClient(); + const casesClient = await context.cases.getCasesClient(); const theCase = request.body as CasePostRequest; return response.ok({ - body: await casesClient.create({ ...theCase }), + body: await casesClient.cases.create({ ...theCase }), }); } catch (error) { logger.error(`Failed to post case in route: ${error}`); diff --git a/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts deleted file mode 100644 index 378d092c8be0b..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/push_case.test.ts +++ /dev/null @@ -1,466 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, - mockCaseConfigure, - mockCaseMappings, - mockUserActions, - mockCaseComments, -} from '../__fixtures__'; -import { initPushCaseApi } from './push_case'; -import { CasesRequestHandlerContext } from '../../../types'; -import { getCasePushUrl } from '../../../../common'; - -describe('Push case', () => { - let routeHandler: RequestHandler; - const mockDate = '2019-11-25T21:54:48.952Z'; - const caseId = 'mock-id-3'; - const connectorId = '123'; - const path = getCasePushUrl(caseId, connectorId); - - beforeAll(async () => { - routeHandler = await createRoute(initPushCaseApi, 'post'); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue(mockDate), - })); - }); - - it(`Pushes a case`, async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - connector_id: connectorId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.external_service).toEqual({ - connector_id: connectorId, - connector_name: 'ServiceNow', - external_id: '10663', - external_title: 'RJ2-200', - external_url: 'https://siem-kibana.atlassian.net/browse/RJ2-200', - pushed_at: mockDate, - pushed_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - }); - }); - - it(`Pushes a case with comments`, async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - connector_id: connectorId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - caseCommentSavedObject: [mockCaseComments[0]], - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.comments[0].pushed_at).toEqual(mockDate); - expect(response.payload.comments[0].pushed_by).toEqual({ - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }); - }); - - it(`Filters comments with type alert correctly`, async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - connector_id: connectorId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - caseCommentSavedObject: [mockCaseComments[0], mockCaseComments[3]], - }) - ); - - const casesClient = context.cases.getCasesClient(); - casesClient.getAlerts = jest.fn().mockResolvedValue([]); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(casesClient.getAlerts).toHaveBeenCalledWith({ - alertsInfo: [{ id: 'test-id', index: 'test-index' }], - }); - }); - - it(`Calls execute with correct arguments`, async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - connector_id: 'for-mock-case-id-3', - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - const actionsClient = context.actions.getActionsClient(); - - await routeHandler(context, request, kibanaResponseFactory); - expect(actionsClient.execute).toHaveBeenCalledWith({ - actionId: 'for-mock-case-id-3', - params: { - subAction: 'pushToService', - subActionParams: { - incident: { - issueType: 'Task', - parent: null, - priority: 'High', - labels: ['LOLBins'], - summary: 'Another bad one', - description: - 'Oh no, a bad meanie going LOLBins all over the place! (created at 2019-11-25T22:32:17.947Z by elastic)', - externalId: null, - }, - comments: [], - }, - }, - }); - }); - - it(`Pushes a case and closes when closure_type: 'close-by-pushing'`, async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - connector_id: connectorId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseUserActionsSavedObject: mockUserActions, - caseConfigureSavedObject: [ - { - ...mockCaseConfigure[0], - attributes: { - ...mockCaseConfigure[0].attributes, - closure_type: 'close-by-pushing', - }, - }, - ], - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(response.payload.closed_at).toEqual(mockDate); - }); - - it(`post the correct user action`, async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - connector_id: connectorId, - }, - body: {}, - }); - - const { context, services } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - services.userActionService.postUserActions = jest.fn(); - const postUserActions = services.userActionService.postUserActions as jest.Mock; - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(200); - expect(postUserActions.mock.calls[0][0].actions[0].attributes).toEqual({ - action: 'push-to-service', - action_at: '2019-11-25T21:54:48.952Z', - action_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - action_field: ['pushed'], - new_value: - '{"pushed_at":"2019-11-25T21:54:48.952Z","pushed_by":{"username":"awesome","full_name":"Awesome D00d","email":"d00d@awesome.com"},"connector_id":"123","connector_name":"ServiceNow","external_id":"10663","external_title":"RJ2-200","external_url":"https://siem-kibana.atlassian.net/browse/RJ2-200"}', - old_value: null, - }); - }); - - it('Unhappy path - case id is missing', async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - connector_id: connectorId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - const res = await routeHandler(context, request, kibanaResponseFactory); - expect(res.status).toEqual(400); - }); - - it('Unhappy path - connector id is missing', async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - const res = await routeHandler(context, request, kibanaResponseFactory); - expect(res.status).toEqual(400); - }); - - it('Unhappy path - case does not exists', async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: 'not-exist', - connector_id: connectorId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - const res = await routeHandler(context, request, kibanaResponseFactory); - expect(res.status).toEqual(404); - }); - - it('Unhappy path - connector does not exists', async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - connector_id: 'not-exists', - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - const res = await routeHandler(context, request, kibanaResponseFactory); - expect(res.status).toEqual(404); - }); - - it('Unhappy path - cannot push to a closed case', async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: 'mock-id-4', - connector_id: connectorId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - const res = await routeHandler(context, request, kibanaResponseFactory); - expect(res.status).toEqual(409); - expect(res.payload.output.payload.message).toBe( - 'This case Another bad one is closed. You can not pushed if the case is closed.' - ); - }); - - it('Unhappy path - throws when external service returns an error', async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - connector_id: connectorId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - const actionsClient = context.actions.getActionsClient(); - (actionsClient.execute as jest.Mock).mockResolvedValue({ - status: 'error', - }); - - const res = await routeHandler(context, request, kibanaResponseFactory); - expect(res.status).toEqual(424); - expect(res.payload.output.payload.message).toBe('Error pushing to service'); - }); - - it('Unhappy path - context case missing', async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - connector_id: connectorId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - const betterContext = ({ - ...context, - cases: null, - } as unknown) as CasesRequestHandlerContext; - - const res = await routeHandler(betterContext, request, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload).toEqual('RouteHandlerContext is not registered for cases'); - }); - - it('Unhappy path - context actions missing', async () => { - const request = httpServerMock.createKibanaRequest({ - path, - method: 'post', - params: { - case_id: caseId, - connector_id: connectorId, - }, - body: {}, - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseMappingsSavedObject: mockCaseMappings, - caseConfigureSavedObject: mockCaseConfigure, - caseUserActionsSavedObject: mockUserActions, - }) - ); - - const betterContext = ({ - ...context, - actions: null, - } as unknown) as CasesRequestHandlerContext; - - const res = await routeHandler(betterContext, request, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload).toEqual('Action client not found'); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/push_case.ts b/x-pack/plugins/cases/server/routes/api/cases/push_case.ts index 9bfb30e0d63ad..a49e1a99c418f 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/push_case.ts @@ -31,12 +31,7 @@ export function initPushCaseApi({ router, logger }: RouteDeps) { return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); } - const casesClient = context.cases.getCasesClient(); - const actionsClient = context.actions?.getActionsClient(); - - if (actionsClient == null) { - return response.badRequest({ body: 'Action client not found' }); - } + const casesClient = await context.cases.getCasesClient(); const params = pipe( CasePushRequestParamsRt.decode(request.params), @@ -44,8 +39,7 @@ export function initPushCaseApi({ router, logger }: RouteDeps) { ); return response.ok({ - body: await casesClient.push({ - actionsClient, + body: await casesClient.cases.push({ caseId: params.case_id, connectorId: params.connector_id, }), diff --git a/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts index 53fdc298ef267..a7a0e4f8bb141 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/reporters/get_reporters.ts @@ -5,24 +5,29 @@ * 2.0. */ -import { UsersRt } from '../../../../../common'; import { RouteDeps } from '../../types'; -import { wrapError } from '../../utils'; -import { CASE_REPORTERS_URL } from '../../../../../common'; +import { wrapError, escapeHatch } from '../../utils'; +import { CASE_REPORTERS_URL } from '../../../../../common/constants'; +import { AllReportersFindRequest } from '../../../../../common/api'; -export function initGetReportersApi({ caseService, router, logger }: RouteDeps) { +export function initGetReportersApi({ router, logger }: RouteDeps) { router.get( { path: CASE_REPORTERS_URL, - validate: {}, + validate: { + query: escapeHatch, + }, }, async (context, request, response) => { try { - const client = context.core.savedObjects.client; - const reporters = await caseService.getReporters({ - client, - }); - return response.ok({ body: UsersRt.encode(reporters) }); + if (!context.cases) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } + + const client = await context.cases.getCasesClient(); + const options = request.query as AllReportersFindRequest; + + return response.ok({ body: await client.cases.getReporters({ ...options }) }); } catch (error) { logger.error(`Failed to get reporters in route: ${error}`); return response.customError(wrapError(error)); diff --git a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts deleted file mode 100644 index 60ad0c60f944f..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCases, -} from '../../__fixtures__'; -import { initGetCasesStatusApi } from './get_status'; -import { CASE_STATUS_URL } from '../../../../../common'; -import { CaseType } from '../../../../../common'; - -describe('GET status', () => { - let routeHandler: RequestHandler; - const findArgs = { - fields: [], - page: 1, - perPage: 1, - type: 'cases', - sortField: 'created_at', - }; - - beforeAll(async () => { - routeHandler = await createRoute(initGetCasesStatusApi, 'get'); - }); - - it(`returns the status`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_STATUS_URL, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { - ...findArgs, - filter: `((cases.attributes.status: open AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, - }); - - expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { - ...findArgs, - filter: `((cases.attributes.status: in-progress AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, - }); - - expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { - ...findArgs, - filter: `((cases.attributes.status: closed AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, - }); - - expect(response.payload).toEqual({ - count_open_cases: 4, - count_in_progress_cases: 4, - count_closed_cases: 4, - }); - }); - - it(`returns an error when findCases throws`, async () => { - const request = httpServerMock.createKibanaRequest({ - path: CASE_STATUS_URL, - method: 'get', - }); - - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: [{ ...mockCases[0], id: 'throw-error-find' }], - }) - ); - - const response = await routeHandler(context, request, kibanaResponseFactory); - expect(response.status).toEqual(404); - }); -}); diff --git a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts deleted file mode 100644 index 73642fdee0eac..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/status/get_status.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { RouteDeps } from '../../types'; -import { wrapError } from '../../utils'; - -import { CasesStatusResponseRt, caseStatuses } from '../../../../../common'; -import { CASE_STATUS_URL } from '../../../../../common'; -import { constructQueryOptions } from '../helpers'; - -export function initGetCasesStatusApi({ caseService, router, logger }: RouteDeps) { - router.get( - { - path: CASE_STATUS_URL, - validate: {}, - }, - async (context, request, response) => { - try { - const client = context.core.savedObjects.client; - - const [openCases, inProgressCases, closedCases] = await Promise.all([ - ...caseStatuses.map((status) => { - const statusQuery = constructQueryOptions({ status }); - return caseService.findCaseStatusStats({ - client, - caseOptions: statusQuery.case, - subCaseOptions: statusQuery.subCase, - }); - }), - ]); - - return response.ok({ - body: CasesStatusResponseRt.encode({ - count_open_cases: openCases, - count_in_progress_cases: inProgressCases, - count_closed_cases: closedCases, - }), - }); - } catch (error) { - logger.error(`Failed to get status stats in route: ${error}`); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts deleted file mode 100644 index ef60c743ec822..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/delete_sub_cases.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { schema } from '@kbn/config-schema'; -import { buildCaseUserActionItem } from '../../../../services/user_actions/helpers'; -import { RouteDeps } from '../../types'; -import { wrapError } from '../../utils'; -import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; - -export function initDeleteSubCasesApi({ - caseService, - router, - userActionService, - logger, -}: RouteDeps) { - router.delete( - { - path: SUB_CASES_PATCH_DEL_URL, - validate: { - query: schema.object({ - ids: schema.arrayOf(schema.string()), - }), - }, - }, - async (context, request, response) => { - try { - const client = context.core.savedObjects.client; - - const [comments, subCases] = await Promise.all([ - caseService.getAllSubCaseComments({ client, id: request.query.ids }), - caseService.getSubCases({ client, ids: request.query.ids }), - ]); - - const subCaseErrors = subCases.saved_objects.filter( - (subCase) => subCase.error !== undefined - ); - - if (subCaseErrors.length > 0) { - throw Boom.notFound( - `These sub cases ${subCaseErrors - .map((c) => c.id) - .join(', ')} do not exist. Please check you have the correct ids.` - ); - } - - const subCaseIDToParentID = subCases.saved_objects.reduce((acc, subCase) => { - const parentID = subCase.references.find((ref) => ref.type === CASE_SAVED_OBJECT); - acc.set(subCase.id, parentID?.id); - return acc; - }, new Map()); - - await Promise.all( - comments.saved_objects.map((comment) => - caseService.deleteComment({ client, commentId: comment.id }) - ) - ); - - await Promise.all(request.query.ids.map((id) => caseService.deleteSubCase(client, id))); - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const deleteDate = new Date().toISOString(); - - await userActionService.postUserActions({ - client, - actions: request.query.ids.map((id) => - buildCaseUserActionItem({ - action: 'delete', - actionAt: deleteDate, - actionBy: { username, full_name, email }, - // if for some reason the sub case didn't have a reference to its parent, we'll still log a user action - // but we won't have the case ID - caseId: subCaseIDToParentID.get(id) ?? '', - subCaseId: id, - fields: ['sub_case', 'comment', 'status'], - }) - ), - }); - - return response.noContent(); - } catch (error) { - logger.error( - `Failed to delete sub cases in route ids: ${JSON.stringify(request.query.ids)}: ${error}` - ); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts deleted file mode 100644 index e069ceda14df9..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/find_sub_cases.ts +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 Boom from '@hapi/boom'; - -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; - -import { - caseStatuses, - SubCasesFindRequestRt, - SubCasesFindResponseRt, - throwErrors, -} from '../../../../../common'; -import { RouteDeps } from '../../types'; -import { escapeHatch, transformSubCases, wrapError } from '../../utils'; -import { SUB_CASES_URL } from '../../../../../common'; -import { constructQueryOptions } from '../helpers'; -import { defaultPage, defaultPerPage } from '../..'; - -export function initFindSubCasesApi({ caseService, router, logger }: RouteDeps) { - router.get( - { - path: `${SUB_CASES_URL}/_find`, - validate: { - params: schema.object({ - case_id: schema.string(), - }), - query: escapeHatch, - }, - }, - async (context, request, response) => { - try { - const client = context.core.savedObjects.client; - const queryParams = pipe( - SubCasesFindRequestRt.decode(request.query), - fold(throwErrors(Boom.badRequest), identity) - ); - - const ids = [request.params.case_id]; - const { subCase: subCaseQueryOptions } = constructQueryOptions({ - status: queryParams.status, - sortByField: queryParams.sortField, - }); - - const subCases = await caseService.findSubCasesGroupByCase({ - client, - ids, - options: { - sortField: 'created_at', - page: defaultPage, - perPage: defaultPerPage, - ...queryParams, - ...subCaseQueryOptions, - }, - }); - - const [open, inProgress, closed] = await Promise.all([ - ...caseStatuses.map((status) => { - const { subCase: statusQueryOptions } = constructQueryOptions({ - status, - sortByField: queryParams.sortField, - }); - return caseService.findSubCaseStatusStats({ - client, - options: statusQueryOptions ?? {}, - ids, - }); - }), - ]); - - return response.ok({ - body: SubCasesFindResponseRt.encode( - transformSubCases({ - page: subCases.page, - perPage: subCases.perPage, - total: subCases.total, - subCasesMap: subCases.subCasesMap, - open, - inProgress, - closed, - }) - ), - }); - } catch (error) { - logger.error( - `Failed to find sub cases in route case id: ${request.params.case_id}: ${error}` - ); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts b/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts deleted file mode 100644 index b5ebfb4de348b..0000000000000 --- a/x-pack/plugins/cases/server/routes/api/cases/sub_case/get_sub_case.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { SubCaseResponseRt } from '../../../../../common'; -import { RouteDeps } from '../../types'; -import { flattenSubCaseSavedObject, wrapError } from '../../utils'; -import { SUB_CASE_DETAILS_URL } from '../../../../../common'; -import { countAlertsForID } from '../../../../common'; - -export function initGetSubCaseApi({ caseService, router, logger }: RouteDeps) { - router.get( - { - path: SUB_CASE_DETAILS_URL, - validate: { - params: schema.object({ - case_id: schema.string(), - sub_case_id: schema.string(), - }), - query: schema.object({ - includeComments: schema.boolean({ defaultValue: true }), - }), - }, - }, - async (context, request, response) => { - try { - const client = context.core.savedObjects.client; - const includeComments = request.query.includeComments; - - const subCase = await caseService.getSubCase({ - client, - id: request.params.sub_case_id, - }); - - if (!includeComments) { - return response.ok({ - body: SubCaseResponseRt.encode( - flattenSubCaseSavedObject({ - savedObject: subCase, - }) - ), - }); - } - - const theComments = await caseService.getAllSubCaseComments({ - client, - id: request.params.sub_case_id, - options: { - sortField: 'created_at', - sortOrder: 'asc', - }, - }); - - return response.ok({ - body: SubCaseResponseRt.encode( - flattenSubCaseSavedObject({ - savedObject: subCase, - comments: theComments.saved_objects, - totalComment: theComments.total, - totalAlerts: countAlertsForID({ - comments: theComments, - id: request.params.sub_case_id, - }), - }) - ), - }); - } catch (error) { - logger.error( - `Failed to get sub case in route case id: ${request.params.case_id} sub case id: ${request.params.sub_case_id} include comments: ${request.query?.includeComments}: ${error}` - ); - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts b/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts index d70d6e0b57ee9..a62c3247b01df 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/tags/get_tags.ts @@ -6,23 +6,30 @@ */ import { RouteDeps } from '../../types'; -import { wrapError } from '../../utils'; -import { CASE_TAGS_URL } from '../../../../../common'; +import { wrapError, escapeHatch } from '../../utils'; +import { CASE_TAGS_URL } from '../../../../../common/constants'; +import { AllTagsFindRequest } from '../../../../../common/api'; -export function initGetTagsApi({ caseService, router }: RouteDeps) { +export function initGetTagsApi({ router, logger }: RouteDeps) { router.get( { path: CASE_TAGS_URL, - validate: {}, + validate: { + query: escapeHatch, + }, }, async (context, request, response) => { try { - const client = context.core.savedObjects.client; - const tags = await caseService.getTags({ - client, - }); - return response.ok({ body: tags }); + if (!context.cases) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } + + const client = await context.cases.getCasesClient(); + const options = request.query as AllTagsFindRequest; + + return response.ok({ body: await client.cases.getTags({ ...options }) }); } catch (error) { + logger.error(`Failed to retrieve tags in route: ${error}`); return response.customError(wrapError(error)); } } diff --git a/x-pack/plugins/cases/server/routes/api/comments/delete_all_comments.ts b/x-pack/plugins/cases/server/routes/api/comments/delete_all_comments.ts new file mode 100644 index 0000000000000..a41d4683af2d0 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/comments/delete_all_comments.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { RouteDeps } from '../types'; +import { wrapError } from '../utils'; +import { CASE_COMMENTS_URL } from '../../../../common/constants'; + +export function initDeleteAllCommentsApi({ router, logger }: RouteDeps) { + router.delete( + { + path: CASE_COMMENTS_URL, + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: schema.maybe( + schema.object({ + subCaseId: schema.maybe(schema.string()), + }) + ), + }, + }, + async (context, request, response) => { + try { + const client = await context.cases.getCasesClient(); + + await client.attachments.deleteAll({ + caseID: request.params.case_id, + subCaseID: request.query?.subCaseId, + }); + + return response.noContent(); + } catch (error) { + logger.error( + `Failed to delete all comments in route case id: ${request.params.case_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/comments/delete_comment.ts b/x-pack/plugins/cases/server/routes/api/comments/delete_comment.ts new file mode 100644 index 0000000000000..f145fc62efc8a --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/comments/delete_comment.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDeps } from '../types'; +import { wrapError } from '../utils'; +import { CASE_COMMENT_DETAILS_URL } from '../../../../common/constants'; + +export function initDeleteCommentApi({ router, logger }: RouteDeps) { + router.delete( + { + path: CASE_COMMENT_DETAILS_URL, + validate: { + params: schema.object({ + case_id: schema.string(), + comment_id: schema.string(), + }), + query: schema.maybe( + schema.object({ + subCaseId: schema.maybe(schema.string()), + }) + ), + }, + }, + async (context, request, response) => { + try { + const client = await context.cases.getCasesClient(); + await client.attachments.delete({ + attachmentID: request.params.comment_id, + subCaseID: request.query?.subCaseId, + caseID: request.params.case_id, + }); + + return response.noContent(); + } catch (error) { + logger.error( + `Failed to delete comment in route case id: ${request.params.case_id} comment id: ${request.params.comment_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts b/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts new file mode 100644 index 0000000000000..a758805deb6ef --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/comments/find_comments.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 Boom from '@hapi/boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { FindQueryParamsRt, throwErrors, excess } from '../../../../common/api'; +import { RouteDeps } from '../types'; +import { escapeHatch, wrapError } from '../utils'; +import { CASE_COMMENTS_URL } from '../../../../common/constants'; + +export function initFindCaseCommentsApi({ router, logger }: RouteDeps) { + router.get( + { + path: `${CASE_COMMENTS_URL}/_find`, + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + excess(FindQueryParamsRt).decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const client = await context.cases.getCasesClient(); + return response.ok({ + body: await client.attachments.find({ + caseID: request.params.case_id, + queryParams: query, + }), + }); + } catch (error) { + logger.error( + `Failed to find comments in route case id: ${request.params.case_id}: ${error}` + ); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts b/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts new file mode 100644 index 0000000000000..b916e22c6b0ed --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/comments/get_all_comment.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDeps } from '../types'; +import { wrapError } from '../utils'; +import { CASE_COMMENTS_URL } from '../../../../common/constants'; + +export function initGetAllCommentsApi({ router, logger }: RouteDeps) { + router.get( + { + path: CASE_COMMENTS_URL, + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: schema.maybe( + schema.object({ + includeSubCaseComments: schema.maybe(schema.boolean()), + subCaseId: schema.maybe(schema.string()), + }) + ), + }, + }, + async (context, request, response) => { + try { + const client = await context.cases.getCasesClient(); + + return response.ok({ + body: await client.attachments.getAll({ + caseID: request.params.case_id, + includeSubCaseComments: request.query?.includeSubCaseComments, + subCaseID: request.query?.subCaseId, + }), + }); + } catch (error) { + logger.error( + `Failed to get all comments in route case id: ${request.params.case_id} include sub case comments: ${request.query?.includeSubCaseComments} sub case id: ${request.query?.subCaseId}: ${error}` + ); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/cases/server/routes/api/comments/get_comment.ts similarity index 59% rename from x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts rename to x-pack/plugins/cases/server/routes/api/comments/get_comment.ts index f86f733306043..09805c00cb10a 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/get_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/comments/get_comment.ts @@ -7,12 +7,11 @@ import { schema } from '@kbn/config-schema'; -import { CommentResponseRt } from '../../../../../common'; -import { RouteDeps } from '../../types'; -import { flattenCommentSavedObject, wrapError } from '../../utils'; -import { CASE_COMMENT_DETAILS_URL } from '../../../../../common'; +import { RouteDeps } from '../types'; +import { wrapError } from '../utils'; +import { CASE_COMMENT_DETAILS_URL } from '../../../../common/constants'; -export function initGetCommentApi({ caseService, router, logger }: RouteDeps) { +export function initGetCommentApi({ router, logger }: RouteDeps) { router.get( { path: CASE_COMMENT_DETAILS_URL, @@ -25,14 +24,13 @@ export function initGetCommentApi({ caseService, router, logger }: RouteDeps) { }, async (context, request, response) => { try { - const client = context.core.savedObjects.client; + const client = await context.cases.getCasesClient(); - const comment = await caseService.getComment({ - client, - commentId: request.params.comment_id, - }); return response.ok({ - body: CommentResponseRt.encode(flattenCommentSavedObject(comment)), + body: await client.attachments.get({ + attachmentID: request.params.comment_id, + caseID: request.params.case_id, + }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/comments/patch_comment.ts b/x-pack/plugins/cases/server/routes/api/comments/patch_comment.ts new file mode 100644 index 0000000000000..aecdeb46756c0 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/comments/patch_comment.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { schema } from '@kbn/config-schema'; +import Boom from '@hapi/boom'; + +import { RouteDeps } from '../types'; +import { escapeHatch, wrapError } from '../utils'; +import { CASE_COMMENTS_URL } from '../../../../common/constants'; +import { CommentPatchRequestRt, throwErrors } from '../../../../common/api'; + +export function initPatchCommentApi({ router, logger }: RouteDeps) { + router.patch( + { + path: CASE_COMMENTS_URL, + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: schema.maybe( + schema.object({ + subCaseId: schema.maybe(schema.string()), + }) + ), + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CommentPatchRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const client = await context.cases.getCasesClient(); + + return response.ok({ + body: await client.attachments.update({ + caseID: request.params.case_id, + subCaseID: request.query?.subCaseId, + updateRequest: query, + }), + }); + } catch (error) { + logger.error( + `Failed to patch comment in route case id: ${request.params.case_id} sub case id: ${request.query?.subCaseId}: ${error}` + ); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/cases/server/routes/api/comments/post_comment.ts similarity index 80% rename from x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts rename to x-pack/plugins/cases/server/routes/api/comments/post_comment.ts index 8af4b86762d33..1919aef7b72b4 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/cases/server/routes/api/comments/post_comment.ts @@ -7,9 +7,10 @@ import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { escapeHatch, wrapError } from '../../utils'; -import { RouteDeps } from '../../types'; -import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR, CommentRequest } from '../../../../../common'; +import { escapeHatch, wrapError } from '../utils'; +import { RouteDeps } from '../types'; +import { CASE_COMMENTS_URL, ENABLE_CASE_CONNECTOR } from '../../../../common/constants'; +import { CommentRequest } from '../../../../common/api'; export function initPostCommentApi({ router, logger }: RouteDeps) { router.post( @@ -39,12 +40,12 @@ export function initPostCommentApi({ router, logger }: RouteDeps) { return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); } - const casesClient = context.cases.getCasesClient(); + const casesClient = await context.cases.getCasesClient(); const caseId = request.query?.subCaseId ?? request.params.case_id; const comment = request.body as CommentRequest; return response.ok({ - body: await casesClient.addComment({ caseId, comment }), + body: await casesClient.attachments.add({ caseId, comment }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/configure/get_configure.ts b/x-pack/plugins/cases/server/routes/api/configure/get_configure.ts new file mode 100644 index 0000000000000..8222ac8fe5690 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/configure/get_configure.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 { RouteDeps } from '../types'; +import { escapeHatch, wrapError } from '../utils'; +import { CASE_CONFIGURE_URL } from '../../../../common/constants'; +import { GetConfigureFindRequest } from '../../../../common/api'; + +export function initGetCaseConfigure({ router, logger }: RouteDeps) { + router.get( + { + path: CASE_CONFIGURE_URL, + validate: { + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const client = await context.cases.getCasesClient(); + const options = request.query as GetConfigureFindRequest; + + return response.ok({ + body: await client.configure.get({ ...options }), + }); + } catch (error) { + logger.error(`Failed to get case configure in route: ${error}`); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/configure/get_connectors.ts b/x-pack/plugins/cases/server/routes/api/configure/get_connectors.ts new file mode 100644 index 0000000000000..46c110bbb8ba5 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/configure/get_connectors.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { RouteDeps } from '../types'; +import { wrapError } from '../utils'; + +import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../common/constants'; + +/* + * Be aware that this api will only return 20 connectors + */ +export function initCaseConfigureGetActionConnector({ router, logger }: RouteDeps) { + router.get( + { + path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, + validate: false, + }, + async (context, request, response) => { + try { + const client = await context.cases.getCasesClient(); + + return response.ok({ body: await client.configure.getConnectors() }); + } catch (error) { + logger.error(`Failed to get connectors in route: ${error}`); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/configure/patch_configure.ts b/x-pack/plugins/cases/server/routes/api/configure/patch_configure.ts new file mode 100644 index 0000000000000..49288c72eadee --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/configure/patch_configure.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + CaseConfigureRequestParamsRt, + throwErrors, + CasesConfigurePatch, + excess, +} from '../../../../common/api'; +import { RouteDeps } from '../types'; +import { wrapError, escapeHatch } from '../utils'; +import { CASE_CONFIGURE_DETAILS_URL } from '../../../../common/constants'; + +export function initPatchCaseConfigure({ router, logger }: RouteDeps) { + router.patch( + { + path: CASE_CONFIGURE_DETAILS_URL, + validate: { + params: escapeHatch, + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const params = pipe( + excess(CaseConfigureRequestParamsRt).decode(request.params), + fold(throwErrors(Boom.badRequest), identity) + ); + + const client = await context.cases.getCasesClient(); + const configuration = request.body as CasesConfigurePatch; + + return response.ok({ + body: await client.configure.update(params.configuration_id, configuration), + }); + } catch (error) { + logger.error(`Failed to get patch configure in route: ${error}`); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/configure/post_configure.ts b/x-pack/plugins/cases/server/routes/api/configure/post_configure.ts new file mode 100644 index 0000000000000..fe8ffedbc85f6 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/configure/post_configure.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { CasesConfigureRequestRt, throwErrors } from '../../../../common/api'; +import { RouteDeps } from '../types'; +import { wrapError, escapeHatch } from '../utils'; +import { CASE_CONFIGURE_URL } from '../../../../common/constants'; + +export function initPostCaseConfigure({ router, logger }: RouteDeps) { + router.post( + { + path: CASE_CONFIGURE_URL, + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const query = pipe( + CasesConfigureRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + const client = await context.cases.getCasesClient(); + + return response.ok({ + body: await client.configure.create(query), + }); + } catch (error) { + logger.error(`Failed to post case configure in route: ${error}`); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/index.ts b/x-pack/plugins/cases/server/routes/api/index.ts index a1635254dd09b..51652408d583b 100644 --- a/x-pack/plugins/cases/server/routes/api/index.ts +++ b/x-pack/plugins/cases/server/routes/api/index.ts @@ -12,31 +12,31 @@ import { initPatchCasesApi } from './cases/patch_cases'; import { initPostCaseApi } from './cases/post_case'; import { initPushCaseApi } from './cases/push_case'; import { initGetReportersApi } from './cases/reporters/get_reporters'; -import { initGetCasesStatusApi } from './cases/status/get_status'; +import { initGetCasesStatusApi } from './stats/get_status'; import { initGetTagsApi } from './cases/tags/get_tags'; import { initGetAllCaseUserActionsApi, initGetAllSubCaseUserActionsApi, -} from './cases/user_actions/get_all_user_actions'; +} from './user_actions/get_all_user_actions'; -import { initDeleteCommentApi } from './cases/comments/delete_comment'; -import { initDeleteAllCommentsApi } from './cases/comments/delete_all_comments'; -import { initFindCaseCommentsApi } from './cases/comments/find_comments'; -import { initGetAllCommentsApi } from './cases/comments/get_all_comment'; -import { initGetCommentApi } from './cases/comments/get_comment'; -import { initPatchCommentApi } from './cases/comments/patch_comment'; -import { initPostCommentApi } from './cases/comments/post_comment'; +import { initDeleteCommentApi } from './comments/delete_comment'; +import { initDeleteAllCommentsApi } from './comments/delete_all_comments'; +import { initFindCaseCommentsApi } from './comments/find_comments'; +import { initGetAllCommentsApi } from './comments/get_all_comment'; +import { initGetCommentApi } from './comments/get_comment'; +import { initPatchCommentApi } from './comments/patch_comment'; +import { initPostCommentApi } from './comments/post_comment'; -import { initCaseConfigureGetActionConnector } from './cases/configure/get_connectors'; -import { initGetCaseConfigure } from './cases/configure/get_configure'; -import { initPatchCaseConfigure } from './cases/configure/patch_configure'; -import { initPostCaseConfigure } from './cases/configure/post_configure'; +import { initCaseConfigureGetActionConnector } from './configure/get_connectors'; +import { initGetCaseConfigure } from './configure/get_configure'; +import { initPatchCaseConfigure } from './configure/patch_configure'; +import { initPostCaseConfigure } from './configure/post_configure'; import { RouteDeps } from './types'; -import { initGetSubCaseApi } from './cases/sub_case/get_sub_case'; -import { initPatchSubCasesApi } from './cases/sub_case/patch_sub_cases'; -import { initFindSubCasesApi } from './cases/sub_case/find_sub_cases'; -import { initDeleteSubCasesApi } from './cases/sub_case/delete_sub_cases'; +import { initGetSubCaseApi } from './sub_case/get_sub_case'; +import { initPatchSubCasesApi } from './sub_case/patch_sub_cases'; +import { initFindSubCasesApi } from './sub_case/find_sub_cases'; +import { initDeleteSubCasesApi } from './sub_case/delete_sub_cases'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; import { initGetCaseIdsByAlertIdApi } from './cases/alerts/get_cases'; diff --git a/x-pack/plugins/cases/server/routes/api/stats/get_status.ts b/x-pack/plugins/cases/server/routes/api/stats/get_status.ts new file mode 100644 index 0000000000000..7fef5f59e2459 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/stats/get_status.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { RouteDeps } from '../types'; +import { escapeHatch, wrapError } from '../utils'; + +import { CASE_STATUS_URL } from '../../../../common/constants'; +import { CasesStatusRequest } from '../../../../common'; + +export function initGetCasesStatusApi({ router, logger }: RouteDeps) { + router.get( + { + path: CASE_STATUS_URL, + validate: { query: escapeHatch }, + }, + async (context, request, response) => { + try { + const client = await context.cases.getCasesClient(); + return response.ok({ + body: await client.stats.getStatusTotalsByType(request.query as CasesStatusRequest), + }); + } catch (error) { + logger.error(`Failed to get status stats in route: ${error}`); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/sub_case/delete_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/sub_case/delete_sub_cases.ts new file mode 100644 index 0000000000000..11b68b70390fe --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/sub_case/delete_sub_cases.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDeps } from '../types'; +import { wrapError } from '../utils'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../common/constants'; + +export function initDeleteSubCasesApi({ router, logger }: RouteDeps) { + router.delete( + { + path: SUB_CASES_PATCH_DEL_URL, + validate: { + query: schema.object({ + ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + try { + const client = await context.cases.getCasesClient(); + await client.subCases.delete(request.query.ids); + + return response.noContent(); + } catch (error) { + logger.error( + `Failed to delete sub cases in route ids: ${JSON.stringify(request.query.ids)}: ${error}` + ); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/sub_case/find_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/sub_case/find_sub_cases.ts new file mode 100644 index 0000000000000..e062f2238439e --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/sub_case/find_sub_cases.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 Boom from '@hapi/boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { SubCasesFindRequestRt, throwErrors } from '../../../../common/api'; +import { RouteDeps } from '../types'; +import { escapeHatch, wrapError } from '../utils'; +import { SUB_CASES_URL } from '../../../../common/constants'; + +export function initFindSubCasesApi({ router, logger }: RouteDeps) { + router.get( + { + path: `${SUB_CASES_URL}/_find`, + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const queryParams = pipe( + SubCasesFindRequestRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const client = await context.cases.getCasesClient(); + return response.ok({ + body: await client.subCases.find({ + caseID: request.params.case_id, + queryParams, + }), + }); + } catch (error) { + logger.error( + `Failed to find sub cases in route case id: ${request.params.case_id}: ${error}` + ); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/sub_case/get_sub_case.ts b/x-pack/plugins/cases/server/routes/api/sub_case/get_sub_case.ts new file mode 100644 index 0000000000000..db3e29f5ed96e --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/sub_case/get_sub_case.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { RouteDeps } from '../types'; +import { wrapError } from '../utils'; +import { SUB_CASE_DETAILS_URL } from '../../../../common/constants'; + +export function initGetSubCaseApi({ router, logger }: RouteDeps) { + router.get( + { + path: SUB_CASE_DETAILS_URL, + validate: { + params: schema.object({ + case_id: schema.string(), + sub_case_id: schema.string(), + }), + query: schema.object({ + includeComments: schema.boolean({ defaultValue: true }), + }), + }, + }, + async (context, request, response) => { + try { + const client = await context.cases.getCasesClient(); + + return response.ok({ + body: await client.subCases.get({ + id: request.params.sub_case_id, + includeComments: request.query.includeComments, + }), + }); + } catch (error) { + logger.error( + `Failed to get sub case in route case id: ${request.params.case_id} sub case id: ${request.params.sub_case_id} include comments: ${request.query?.includeComments}: ${error}` + ); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/sub_case/patch_sub_cases.ts b/x-pack/plugins/cases/server/routes/api/sub_case/patch_sub_cases.ts new file mode 100644 index 0000000000000..1fb260453d188 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/sub_case/patch_sub_cases.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { SubCasesPatchRequest } from '../../../../common/api'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../common/constants'; +import { RouteDeps } from '../types'; +import { escapeHatch, wrapError } from '../utils'; + +export function initPatchSubCasesApi({ router, logger }: RouteDeps) { + router.patch( + { + path: SUB_CASES_PATCH_DEL_URL, + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const casesClient = await context.cases.getCasesClient(); + const subCases = request.body as SubCasesPatchRequest; + return response.ok({ + body: await casesClient.subCases.update(subCases), + }); + } catch (error) { + logger.error(`Failed to patch sub cases in route: ${error}`); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/types.ts b/x-pack/plugins/cases/server/routes/api/types.ts index 6ce40e01c7752..9211aee5606a6 100644 --- a/x-pack/plugins/cases/server/routes/api/types.ts +++ b/x-pack/plugins/cases/server/routes/api/types.ts @@ -7,30 +7,13 @@ import type { Logger } from 'kibana/server'; -import type { - CaseConfigureServiceSetup, - CaseServiceSetup, - CaseUserActionServiceSetup, - ConnectorMappingsServiceSetup, -} from '../../services'; - import type { CasesRouter } from '../../types'; export interface RouteDeps { - caseConfigureService: CaseConfigureServiceSetup; - caseService: CaseServiceSetup; - connectorMappingsService: ConnectorMappingsServiceSetup; router: CasesRouter; - userActionService: CaseUserActionServiceSetup; logger: Logger; } -export enum SortFieldCase { - closedAt = 'closed_at', - createdAt = 'created_at', - status = 'status', -} - export interface TotalCommentByCase { caseId: string; totalComments: number; diff --git a/x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts similarity index 84% rename from x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts rename to x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts index 48393b6af34ae..5944ff6176d78 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/cases/server/routes/api/user_actions/get_all_user_actions.ts @@ -7,9 +7,9 @@ import { schema } from '@kbn/config-schema'; -import { RouteDeps } from '../../types'; -import { wrapError } from '../../utils'; -import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../../common'; +import { RouteDeps } from '../types'; +import { wrapError } from '../utils'; +import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../common/constants'; export function initGetAllCaseUserActionsApi({ router, logger }: RouteDeps) { router.get( @@ -27,11 +27,11 @@ export function initGetAllCaseUserActionsApi({ router, logger }: RouteDeps) { return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); } - const casesClient = context.cases.getCasesClient(); + const casesClient = await context.cases.getCasesClient(); const caseId = request.params.case_id; return response.ok({ - body: await casesClient.getUserActions({ caseId }), + body: await casesClient.userActions.getAll({ caseId }), }); } catch (error) { logger.error( @@ -60,12 +60,12 @@ export function initGetAllSubCaseUserActionsApi({ router, logger }: RouteDeps) { return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); } - const casesClient = context.cases.getCasesClient(); + const casesClient = await context.cases.getCasesClient(); const caseId = request.params.case_id; const subCaseId = request.params.sub_case_id; return response.ok({ - body: await casesClient.getUserActions({ caseId, subCaseId }), + body: await casesClient.userActions.getAll({ caseId, subCaseId }), }); } catch (error) { logger.error( diff --git a/x-pack/plugins/cases/server/routes/api/utils.test.ts b/x-pack/plugins/cases/server/routes/api/utils.test.ts index 2df17e3abacfa..3fce38b27446e 100644 --- a/x-pack/plugins/cases/server/routes/api/utils.test.ts +++ b/x-pack/plugins/cases/server/routes/api/utils.test.ts @@ -5,314 +5,10 @@ * 2.0. */ -import { - transformNewCase, - transformNewComment, - wrapError, - transformCases, - flattenCaseSavedObject, - flattenCommentSavedObjects, - transformComments, - flattenCommentSavedObject, - sortToSnake, -} from './utils'; -import { newCase } from './__mocks__/request_responses'; +import { wrapError } from './utils'; import { isBoom, boomify } from '@hapi/boom'; -import { - mockCases, - mockCaseComments, - mockCaseNoConnectorId, -} from './__fixtures__/mock_saved_objects'; -import { - ConnectorTypes, - ESCaseConnector, - CommentType, - AssociationType, - CaseType, - CaseResponse, -} from '../../../common'; describe('Utils', () => { - describe('transformNewCase', () => { - const connector: ESCaseConnector = { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], - }; - it('transform correctly', () => { - const myCase = { - newCase: { ...newCase, type: CaseType.individual }, - connector, - createdDate: '2020-04-09T09:43:51.778Z', - email: 'elastic@elastic.co', - full_name: 'Elastic', - username: 'elastic', - }; - - const res = transformNewCase(myCase); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": "elastic@elastic.co", - "full_name": "Elastic", - "username": "elastic", - }, - "description": "A description", - "external_service": null, - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "tags": Array [ - "new", - "case", - ], - "title": "My new case", - "type": "individual", - "updated_at": null, - "updated_by": null, - } - `); - }); - - it('transform correctly without optional fields', () => { - const myCase = { - newCase: { ...newCase, type: CaseType.individual }, - connector, - createdDate: '2020-04-09T09:43:51.778Z', - }; - - const res = transformNewCase(myCase); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": undefined, - "full_name": undefined, - "username": undefined, - }, - "description": "A description", - "external_service": null, - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "tags": Array [ - "new", - "case", - ], - "title": "My new case", - "type": "individual", - "updated_at": null, - "updated_by": null, - } - `); - }); - - it('transform correctly with optional fields as null', () => { - const myCase = { - newCase: { ...newCase, type: CaseType.individual }, - connector, - createdDate: '2020-04-09T09:43:51.778Z', - email: null, - full_name: null, - username: null, - }; - - const res = transformNewCase(myCase); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "description": "A description", - "external_service": null, - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "tags": Array [ - "new", - "case", - ], - "title": "My new case", - "type": "individual", - "updated_at": null, - "updated_by": null, - } - `); - }); - }); - - describe('transformNewComment', () => { - it('transforms correctly', () => { - const comment = { - comment: 'A comment', - type: CommentType.user as const, - createdDate: '2020-04-09T09:43:51.778Z', - email: 'elastic@elastic.co', - full_name: 'Elastic', - username: 'elastic', - associationType: AssociationType.case, - }; - - const res = transformNewComment(comment); - expect(res).toMatchInlineSnapshot(` - Object { - "associationType": "case", - "comment": "A comment", - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": "elastic@elastic.co", - "full_name": "Elastic", - "username": "elastic", - }, - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - } - `); - }); - - it('transform correctly without optional fields', () => { - const comment = { - comment: 'A comment', - type: CommentType.user as const, - createdDate: '2020-04-09T09:43:51.778Z', - associationType: AssociationType.case, - }; - - const res = transformNewComment(comment); - - expect(res).toMatchInlineSnapshot(` - Object { - "associationType": "case", - "comment": "A comment", - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": undefined, - "full_name": undefined, - "username": undefined, - }, - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - } - `); - }); - - it('transform correctly with optional fields as null', () => { - const comment = { - comment: 'A comment', - type: CommentType.user as const, - createdDate: '2020-04-09T09:43:51.778Z', - email: null, - full_name: null, - username: null, - associationType: AssociationType.case, - }; - - const res = transformNewComment(comment); - - expect(res).toMatchInlineSnapshot(` - Object { - "associationType": "case", - "comment": "A comment", - "created_at": "2020-04-09T09:43:51.778Z", - "created_by": Object { - "email": null, - "full_name": null, - "username": null, - }, - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": null, - "updated_by": null, - } - `); - }); - }); - describe('wrapError', () => { it('wraps an error', () => { const error = new Error('Something happened'); @@ -358,532 +54,4 @@ describe('Utils', () => { expect(res.headers).toEqual({}); }); }); - - describe('transformCases', () => { - it('transforms correctly', () => { - const casesMap = new Map( - mockCases.map((obj) => { - return [obj.id, flattenCaseSavedObject({ savedObject: obj, totalComment: 2 })]; - }) - ); - const res = transformCases({ - casesMap, - countOpenCases: 2, - countInProgressCases: 2, - countClosedCases: 2, - page: 1, - perPage: 10, - total: casesMap.size, - }); - expect(res).toMatchInlineSnapshot(` - Object { - "cases": Array [ - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-id-1", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - }, - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T22:32:00.900Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie destroying data!", - "external_service": null, - "id": "mock-id-2", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "Data Destruction", - ], - "title": "Damaging Data Destruction Detected", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:00.900Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzQsMV0=", - }, - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", - }, - Object { - "closed_at": "2019-11-25T22:32:17.947Z", - "closed_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-4", - "settings": Object { - "syncAlerts": true, - }, - "status": "closed", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", - }, - ], - "count_closed_cases": 2, - "count_in_progress_cases": 2, - "count_open_cases": 2, - "page": 1, - "per_page": 10, - "total": 4, - } - `); - }); - }); - - describe('flattenCaseSavedObject', () => { - it('flattens correctly', () => { - const myCase = { ...mockCases[2] }; - const res = flattenCaseSavedObject({ - savedObject: myCase, - totalComment: 2, - }); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", - } - `); - }); - - it('flattens correctly without version', () => { - const myCase = { ...mockCases[2] }; - myCase.version = undefined; - const res = flattenCaseSavedObject({ - savedObject: myCase, - totalComment: 2, - }); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "0", - } - `); - }); - - it('flattens correctly with comments', () => { - const myCase = { ...mockCases[2] }; - const comments = [{ ...mockCaseComments[0] }]; - const res = flattenCaseSavedObject({ - savedObject: myCase, - comments, - totalComment: 2, - }); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [ - Object { - "associationType": "case", - "comment": "Wow, good luck catching that bad meanie!", - "created_at": "2019-11-25T21:55:00.177Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "id": "mock-comment-1", - "pushed_at": null, - "pushed_by": null, - "type": "user", - "updated_at": "2019-11-25T21:55:00.177Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzEsMV0=", - }, - ], - "connector": Object { - "fields": Object { - "issueType": "Task", - "parent": null, - "priority": "High", - }, - "id": "123", - "name": "My connector", - "type": ".jira", - }, - "created_at": "2019-11-25T22:32:17.947Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "Oh no, a bad meanie going LOLBins all over the place!", - "external_service": null, - "id": "mock-id-3", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "LOLBins", - ], - "title": "Another bad one", - "totalAlerts": 0, - "totalComment": 2, - "type": "individual", - "updated_at": "2019-11-25T22:32:17.947Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzUsMV0=", - } - `); - }); - - it('inserts missing connector', () => { - const extraCaseData = { - totalComment: 2, - }; - - const res = flattenCaseSavedObject({ - // @ts-ignore this is to update old case saved objects to include connector - savedObject: mockCaseNoConnectorId, - ...extraCaseData, - }); - - expect(res).toMatchInlineSnapshot(` - Object { - "closed_at": null, - "closed_by": null, - "comments": Array [], - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - "created_at": "2019-11-25T21:54:48.952Z", - "created_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "description": "This is a brand new case of a bad meanie defacing data", - "external_service": null, - "id": "mock-no-connector_id", - "settings": Object { - "syncAlerts": true, - }, - "status": "open", - "subCaseIds": undefined, - "subCases": undefined, - "tags": Array [ - "defacement", - ], - "title": "Super Bad Security Issue", - "totalAlerts": 0, - "totalComment": 2, - "updated_at": "2019-11-25T21:54:48.952Z", - "updated_by": Object { - "email": "testemail@elastic.co", - "full_name": "elastic", - "username": "elastic", - }, - "version": "WzAsMV0=", - } - `); - }); - }); - - describe('transformComments', () => { - it('transforms correctly', () => { - const comments = { - saved_objects: mockCaseComments.map((obj) => ({ ...obj, score: 1 })), - total: mockCaseComments.length, - per_page: 10, - page: 1, - }; - - const res = transformComments(comments); - expect(res).toEqual({ - page: 1, - per_page: 10, - total: mockCaseComments.length, - comments: flattenCommentSavedObjects(comments.saved_objects), - }); - }); - }); - - describe('flattenCommentSavedObjects', () => { - it('flattens correctly', () => { - const comments = [{ ...mockCaseComments[0] }, { ...mockCaseComments[1] }]; - const res = flattenCommentSavedObjects(comments); - expect(res).toEqual([ - flattenCommentSavedObject(comments[0]), - flattenCommentSavedObject(comments[1]), - ]); - }); - }); - - describe('flattenCommentSavedObject', () => { - it('flattens correctly', () => { - const comment = { ...mockCaseComments[0] }; - const res = flattenCommentSavedObject(comment); - expect(res).toEqual({ - id: comment.id, - version: comment.version, - ...comment.attributes, - }); - }); - - it('flattens correctly without version', () => { - const comment = { ...mockCaseComments[0] }; - comment.version = undefined; - const res = flattenCommentSavedObject(comment); - expect(res).toEqual({ - id: comment.id, - version: '0', - ...comment.attributes, - }); - }); - }); - - describe('sortToSnake', () => { - it('it transforms status correctly', () => { - expect(sortToSnake('status')).toBe('status'); - }); - - it('it transforms createdAt correctly', () => { - expect(sortToSnake('createdAt')).toBe('created_at'); - }); - - it('it transforms created_at correctly', () => { - expect(sortToSnake('created_at')).toBe('created_at'); - }); - - it('it transforms closedAt correctly', () => { - expect(sortToSnake('closedAt')).toBe('closed_at'); - }); - - it('it transforms closed_at correctly', () => { - expect(sortToSnake('closed_at')).toBe('closed_at'); - }); - - it('it transforms default correctly', () => { - expect(sortToSnake('not-exist')).toBe('created_at'); - }); - }); }); diff --git a/x-pack/plugins/cases/server/routes/api/utils.ts b/x-pack/plugins/cases/server/routes/api/utils.ts index 9234472c13f5d..f7a77a5dbf391 100644 --- a/x-pack/plugins/cases/server/routes/api/utils.ts +++ b/x-pack/plugins/cases/server/routes/api/utils.ts @@ -5,180 +5,12 @@ * 2.0. */ -import { isEmpty } from 'lodash'; -import { badRequest, Boom, boomify, isBoom } from '@hapi/boom'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { schema } from '@kbn/config-schema'; -import { - CustomHttpResponseOptions, - ResponseError, - SavedObject, - SavedObjectsFindResponse, -} from 'kibana/server'; - -import { - CaseResponse, - CasesFindResponse, - CommentResponse, - CommentsResponse, - CommentAttributes, - ESCaseConnector, - ESCaseAttributes, - CommentRequest, - ContextTypeUserRt, - CommentRequestUserType, - CommentRequestAlertType, - CommentType, - excess, - throwErrors, - CaseStatuses, - CasesClientPostRequest, - AssociationType, - SubCaseAttributes, - SubCaseResponse, - SubCasesFindResponse, - User, - AlertCommentRequestRt, -} from '../../../common'; -import { transformESConnectorToCaseConnector } from './cases/helpers'; +import { Boom, boomify, isBoom } from '@hapi/boom'; -import { SortFieldCase } from './types'; -import { AlertInfo } from '../../common'; +import { schema } from '@kbn/config-schema'; +import { CustomHttpResponseOptions, ResponseError } from 'kibana/server'; import { isCaseError } from '../../common/error'; -export const transformNewSubCase = ({ - createdAt, - createdBy, -}: { - createdAt: string; - createdBy: User; -}): SubCaseAttributes => { - return { - closed_at: null, - closed_by: null, - created_at: createdAt, - created_by: createdBy, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }; -}; - -export const transformNewCase = ({ - connector, - createdDate, - email, - // eslint-disable-next-line @typescript-eslint/naming-convention - full_name, - newCase, - username, -}: { - connector: ESCaseConnector; - createdDate: string; - email?: string | null; - full_name?: string | null; - newCase: CasesClientPostRequest; - username?: string | null; -}): ESCaseAttributes => ({ - ...newCase, - closed_at: null, - closed_by: null, - connector, - created_at: createdDate, - created_by: { email, full_name, username }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, -}); - -type NewCommentArgs = CommentRequest & { - associationType: AssociationType; - createdDate: string; - email?: string | null; - full_name?: string | null; - username?: string | null; -}; - -/** - * Return the alert IDs from the comment if it is an alert style comment. Otherwise return an empty array. - */ -export const getAlertIds = (comment: CommentRequest): string[] => { - if (isCommentRequestTypeAlertOrGenAlert(comment)) { - return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; - } - return []; -}; - -const getIDsAndIndicesAsArrays = ( - comment: CommentRequestAlertType -): { ids: string[]; indices: string[] } => { - return { - ids: Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId], - indices: Array.isArray(comment.index) ? comment.index : [comment.index], - }; -}; - -/** - * This functions extracts the ids and indices from an alert comment. It enforces that the alertId and index are either - * both strings or string arrays that are the same length. If they are arrays they represent a 1-to-1 mapping of - * id existing in an index at each position in the array. This is not ideal. Ideally an alert comment request would - * accept an array of objects like this: Array<{id: string; index: string; ruleName: string ruleID: string}> instead. - * - * To reformat the alert comment request requires a migration and a breaking API change. - */ -const getAndValidateAlertInfoFromComment = (comment: CommentRequest): AlertInfo[] => { - if (!isCommentRequestTypeAlertOrGenAlert(comment)) { - return []; - } - - const { ids, indices } = getIDsAndIndicesAsArrays(comment); - - if (ids.length !== indices.length) { - return []; - } - - return ids.map((id, index) => ({ id, index: indices[index] })); -}; - -/** - * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alerts. - */ -export const getAlertInfoFromComments = (comments: CommentRequest[] | undefined): AlertInfo[] => { - if (comments === undefined) { - return []; - } - - return comments.reduce((acc: AlertInfo[], comment) => { - const alertInfo = getAndValidateAlertInfoFromComment(comment); - acc.push(...alertInfo); - return acc; - }, []); -}; - -export const transformNewComment = ({ - associationType, - createdDate, - email, - // eslint-disable-next-line @typescript-eslint/naming-convention - full_name, - username, - ...comment -}: NewCommentArgs): CommentAttributes => { - return { - associationType, - ...comment, - created_at: createdDate, - created_by: { email, full_name, username }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }; -}; - /** * Transforms an error into the correct format for a kibana response. */ @@ -199,222 +31,4 @@ export function wrapError(error: any): CustomHttpResponseOptions }; } -export const transformCases = ({ - casesMap, - countOpenCases, - countInProgressCases, - countClosedCases, - page, - perPage, - total, -}: { - casesMap: Map; - countOpenCases: number; - countInProgressCases: number; - countClosedCases: number; - page: number; - perPage: number; - total: number; -}): CasesFindResponse => ({ - page, - per_page: perPage, - total, - cases: Array.from(casesMap.values()), - count_open_cases: countOpenCases, - count_in_progress_cases: countInProgressCases, - count_closed_cases: countClosedCases, -}); - -export const transformSubCases = ({ - subCasesMap, - open, - inProgress, - closed, - page, - perPage, - total, -}: { - subCasesMap: Map; - open: number; - inProgress: number; - closed: number; - page: number; - perPage: number; - total: number; -}): SubCasesFindResponse => ({ - page, - per_page: perPage, - total, - // Squish all the entries in the map together as one array - subCases: Array.from(subCasesMap.values()).flat(), - count_open_cases: open, - count_in_progress_cases: inProgress, - count_closed_cases: closed, -}); - -export const flattenCaseSavedObject = ({ - savedObject, - comments = [], - totalComment = comments.length, - totalAlerts = 0, - subCases, - subCaseIds, -}: { - savedObject: SavedObject; - comments?: Array>; - totalComment?: number; - totalAlerts?: number; - subCases?: SubCaseResponse[]; - subCaseIds?: string[]; -}): CaseResponse => ({ - id: savedObject.id, - version: savedObject.version ?? '0', - comments: flattenCommentSavedObjects(comments), - totalComment, - totalAlerts, - ...savedObject.attributes, - connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), - subCases, - subCaseIds: !isEmpty(subCaseIds) ? subCaseIds : undefined, -}); - -export const flattenSubCaseSavedObject = ({ - savedObject, - comments = [], - totalComment = comments.length, - totalAlerts = 0, -}: { - savedObject: SavedObject; - comments?: Array>; - totalComment?: number; - totalAlerts?: number; -}): SubCaseResponse => ({ - id: savedObject.id, - version: savedObject.version ?? '0', - comments: flattenCommentSavedObjects(comments), - totalComment, - totalAlerts, - ...savedObject.attributes, -}); - -export const transformComments = ( - comments: SavedObjectsFindResponse -): CommentsResponse => ({ - page: comments.page, - per_page: comments.per_page, - total: comments.total, - comments: flattenCommentSavedObjects(comments.saved_objects), -}); - -export const flattenCommentSavedObjects = ( - savedObjects: Array> -): CommentResponse[] => - savedObjects.reduce((acc: CommentResponse[], savedObject: SavedObject) => { - return [...acc, flattenCommentSavedObject(savedObject)]; - }, []); - -export const flattenCommentSavedObject = ( - savedObject: SavedObject -): CommentResponse => ({ - id: savedObject.id, - version: savedObject.version ?? '0', - ...savedObject.attributes, -}); - -export const sortToSnake = (sortField: string | undefined): SortFieldCase => { - switch (sortField) { - case 'status': - return SortFieldCase.status; - case 'createdAt': - case 'created_at': - return SortFieldCase.createdAt; - case 'closedAt': - case 'closed_at': - return SortFieldCase.closedAt; - default: - return SortFieldCase.createdAt; - } -}; - export const escapeHatch = schema.object({}, { unknowns: 'allow' }); - -/** - * A type narrowing function for user comments. Exporting so integration tests can use it. - */ -export const isCommentRequestTypeUser = ( - context: CommentRequest -): context is CommentRequestUserType => { - return context.type === CommentType.user; -}; - -/** - * A type narrowing function for alert comments. Exporting so integration tests can use it. - */ -export const isCommentRequestTypeAlertOrGenAlert = ( - context: CommentRequest -): context is CommentRequestAlertType => { - return context.type === CommentType.alert || context.type === CommentType.generatedAlert; -}; - -/** - * This is used to test if the posted comment is an generated alert. A generated alert will have one or many alerts. - * An alert is essentially an object with a _id field. This differs from a regular attached alert because the _id is - * passed directly in the request, it won't be in an object. Internally case will strip off the outer object and store - * both a generated and user attached alert in the same structure but this function is useful to determine which - * structure the new alert in the request has. - */ -export const isCommentRequestTypeGenAlert = ( - context: CommentRequest -): context is CommentRequestAlertType => { - return context.type === CommentType.generatedAlert; -}; - -export const decodeCommentRequest = (comment: CommentRequest) => { - if (isCommentRequestTypeUser(comment)) { - pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isCommentRequestTypeAlertOrGenAlert(comment)) { - pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); - const { ids, indices } = getIDsAndIndicesAsArrays(comment); - - /** - * The alertId and index field must either be both of type string or they must both be string[] and be the same length. - * Having a one-to-one relationship between the id and index of an alert avoids accidentally updating or - * retrieving the wrong alert. Elasticsearch only guarantees that the _id (the field we use for alertId) to be - * unique within a single index. So if we attempt to update or get a specific alert across multiple indices we could - * update or receive the wrong one. - * - * Consider the situation where we have a alert1 with _id = '100' in index 'my-index-awesome' and also in index - * 'my-index-hi'. - * If we attempt to update the status of alert1 using an index pattern like `my-index-*` or even providing multiple - * indices, there's a chance we'll accidentally update too many alerts. - * - * This check doesn't enforce that the API request has the correct alert ID to index relationship it just guards - * against accidentally making a request like: - * { - * alertId: [1,2,3], - * index: awesome, - * } - * - * Instead this requires the requestor to provide: - * { - * alertId: [1,2,3], - * index: [awesome, awesome, awesome] - * } - * - * Ideally we'd change the format of the comment request to be an array of objects like: - * { - * alerts: [{id: 1, index: awesome}, {id: 2, index: awesome}] - * } - * - * But we'd need to also implement a migration because the saved object document currently stores the id and index - * in separate fields. - */ - if (ids.length !== indices.length) { - throw badRequest( - `Received an alert comment with ids and indices arrays of different lengths ids: ${JSON.stringify( - ids - )} indices: ${JSON.stringify(indices)}` - ); - } - } -}; diff --git a/x-pack/plugins/cases/server/saved_object_types/cases.ts b/x-pack/plugins/cases/server/saved_object_types/cases.ts index 5f413ea27c4a7..2a260a9bcf2ae 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases.ts @@ -6,13 +6,12 @@ */ import { SavedObjectsType } from 'src/core/server'; +import { CASE_SAVED_OBJECT } from '../../common/constants'; import { caseMigrations } from './migrations'; -export const CASE_SAVED_OBJECT = 'cases'; - export const caseSavedObjectType: SavedObjectsType = { name: CASE_SAVED_OBJECT, - hidden: false, + hidden: true, namespaceType: 'single', mappings: { properties: { @@ -109,6 +108,9 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, + owner: { + type: 'keyword', + }, title: { type: 'keyword', }, diff --git a/x-pack/plugins/cases/server/saved_object_types/comments.ts b/x-pack/plugins/cases/server/saved_object_types/comments.ts index a4fdc24b6e4ee..2ba6e2562a549 100644 --- a/x-pack/plugins/cases/server/saved_object_types/comments.ts +++ b/x-pack/plugins/cases/server/saved_object_types/comments.ts @@ -6,13 +6,12 @@ */ import { SavedObjectsType } from 'src/core/server'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../common/constants'; import { commentsMigrations } from './migrations'; -export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments'; - export const caseCommentSavedObjectType: SavedObjectsType = { name: CASE_COMMENT_SAVED_OBJECT, - hidden: false, + hidden: true, namespaceType: 'single', mappings: { properties: { @@ -22,6 +21,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { comment: { type: 'text', }, + owner: { + type: 'keyword', + }, type: { type: 'keyword', }, diff --git a/x-pack/plugins/cases/server/saved_object_types/configure.ts b/x-pack/plugins/cases/server/saved_object_types/configure.ts index 8944e0678f59c..98a60ac395987 100644 --- a/x-pack/plugins/cases/server/saved_object_types/configure.ts +++ b/x-pack/plugins/cases/server/saved_object_types/configure.ts @@ -6,13 +6,12 @@ */ import { SavedObjectsType } from 'src/core/server'; +import { CASE_CONFIGURE_SAVED_OBJECT } from '../../common/constants'; import { configureMigrations } from './migrations'; -export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure'; - export const caseConfigureSavedObjectType: SavedObjectsType = { name: CASE_CONFIGURE_SAVED_OBJECT, - hidden: false, + hidden: true, namespaceType: 'single', mappings: { properties: { @@ -58,6 +57,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { closure_type: { type: 'keyword', }, + owner: { + type: 'keyword', + }, updated_at: { type: 'date', }, diff --git a/x-pack/plugins/cases/server/saved_object_types/connector_mappings.ts b/x-pack/plugins/cases/server/saved_object_types/connector_mappings.ts index df469108fac0b..16aba01616c3d 100644 --- a/x-pack/plugins/cases/server/saved_object_types/connector_mappings.ts +++ b/x-pack/plugins/cases/server/saved_object_types/connector_mappings.ts @@ -6,12 +6,12 @@ */ import { SavedObjectsType } from 'src/core/server'; - -export const CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT = 'cases-connector-mappings'; +import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../common/constants'; +import { connectorMappingsMigrations } from './migrations'; export const caseConnectorMappingsSavedObjectType: SavedObjectsType = { name: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, - hidden: false, + hidden: true, namespaceType: 'single', mappings: { properties: { @@ -28,6 +28,10 @@ export const caseConnectorMappingsSavedObjectType: SavedObjectsType = { }, }, }, + owner: { + type: 'keyword', + }, }, }, + migrations: connectorMappingsMigrations, }; 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 91f104335df8b..1c6bcf6ca710a 100644 --- a/x-pack/plugins/cases/server/saved_object_types/index.ts +++ b/x-pack/plugins/cases/server/saved_object_types/index.ts @@ -5,12 +5,9 @@ * 2.0. */ -export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; -export { subCaseSavedObjectType, SUB_CASE_SAVED_OBJECT } from './sub_case'; -export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure'; -export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; -export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions'; -export { - caseConnectorMappingsSavedObjectType, - CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, -} from './connector_mappings'; +export { caseSavedObjectType } from './cases'; +export { subCaseSavedObjectType } from './sub_case'; +export { caseConfigureSavedObjectType } from './configure'; +export { caseCommentSavedObjectType } from './comments'; +export { caseUserActionSavedObjectType } from './user_actions'; +export { caseConnectorMappingsSavedObjectType } from './connector_mappings'; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations.ts b/x-pack/plugins/cases/server/saved_object_types/migrations.ts index 8bbc481124870..3d0bab68cf458 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations.ts @@ -14,7 +14,8 @@ import { CaseType, AssociationType, ESConnectorFields, -} from '../../common'; +} from '../../common/api'; +import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; interface UnsanitizedCaseConnector { connector_id: string; @@ -59,6 +60,21 @@ interface SanitizedCaseType { type: string; } +interface SanitizedCaseOwner { + owner: string; +} + +const addOwnerToSO = >( + doc: SavedObjectUnsanitizedDoc +): SavedObjectSanitizedDoc => ({ + ...doc, + attributes: { + ...doc.attributes, + owner: SECURITY_SOLUTION_OWNER, + }, + references: doc.references || [], +}); + export const caseMigrations = { '7.10.0': ( doc: SavedObjectUnsanitizedDoc @@ -113,6 +129,11 @@ export const caseMigrations = { references: doc.references || [], }; }, + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, }; export const configureMigrations = { @@ -135,6 +156,11 @@ export const configureMigrations = { references: doc.references || [], }; }, + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, }; export const userActionsMigrations = { @@ -176,6 +202,11 @@ export const userActionsMigrations = { references: doc.references || [], }; }, + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, }; interface UnsanitizedComment { @@ -226,4 +257,25 @@ export const commentsMigrations = { references: doc.references || [], }; }, + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, +}; + +export const connectorMappingsMigrations = { + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, +}; + +export const subCasesMigrations = { + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, }; diff --git a/x-pack/plugins/cases/server/saved_object_types/sub_case.ts b/x-pack/plugins/cases/server/saved_object_types/sub_case.ts index da89b19346e4e..471dfebe74ae1 100644 --- a/x-pack/plugins/cases/server/saved_object_types/sub_case.ts +++ b/x-pack/plugins/cases/server/saved_object_types/sub_case.ts @@ -6,12 +6,12 @@ */ import { SavedObjectsType } from 'src/core/server'; - -export const SUB_CASE_SAVED_OBJECT = 'cases-sub-case'; +import { SUB_CASE_SAVED_OBJECT } from '../../common/constants'; +import { subCasesMigrations } from './migrations'; export const subCaseSavedObjectType: SavedObjectsType = { name: SUB_CASE_SAVED_OBJECT, - hidden: false, + hidden: true, namespaceType: 'single', mappings: { properties: { @@ -47,6 +47,9 @@ export const subCaseSavedObjectType: SavedObjectsType = { }, }, }, + owner: { + type: 'keyword', + }, status: { type: 'keyword', }, @@ -68,4 +71,5 @@ export const subCaseSavedObjectType: SavedObjectsType = { }, }, }, + migrations: subCasesMigrations, }; diff --git a/x-pack/plugins/cases/server/saved_object_types/user_actions.ts b/x-pack/plugins/cases/server/saved_object_types/user_actions.ts index 745dc10e5aac9..55a79f56f84da 100644 --- a/x-pack/plugins/cases/server/saved_object_types/user_actions.ts +++ b/x-pack/plugins/cases/server/saved_object_types/user_actions.ts @@ -6,13 +6,12 @@ */ import { SavedObjectsType } from 'src/core/server'; +import { CASE_USER_ACTION_SAVED_OBJECT } from '../../common/constants'; import { userActionsMigrations } from './migrations'; -export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions'; - export const caseUserActionSavedObjectType: SavedObjectsType = { name: CASE_USER_ACTION_SAVED_OBJECT, - hidden: false, + hidden: true, namespaceType: 'single', mappings: { properties: { @@ -44,6 +43,9 @@ export const caseUserActionSavedObjectType: SavedObjectsType = { old_value: { type: 'text', }, + owner: { + type: 'keyword', + }, }, }, migrations: userActionsMigrations, diff --git a/x-pack/plugins/cases/server/scripts/sub_cases/index.ts b/x-pack/plugins/cases/server/scripts/sub_cases/index.ts index 56f842c10e8f5..edabe9c4d4a1f 100644 --- a/x-pack/plugins/cases/server/scripts/sub_cases/index.ts +++ b/x-pack/plugins/cases/server/scripts/sub_cases/index.ts @@ -8,7 +8,14 @@ import yargs from 'yargs'; import { ToolingLog } from '@kbn/dev-utils'; import { KbnClient } from '@kbn/test'; -import { CaseResponse, CaseType, CommentType, ConnectorTypes, CASES_URL } from '../../../common'; +import { + CaseResponse, + CaseType, + CommentType, + ConnectorTypes, + CASES_URL, + SECURITY_SOLUTION_OWNER, +} from '../../../common'; import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common'; import { ContextTypeGeneratedAlertType, createAlertsString } from '../../connectors'; @@ -101,6 +108,7 @@ async function handleGenGroupAlerts(argv: any) { console.log('Case id: ', caseID); const comment: ContextTypeGeneratedAlertType = { + owner: SECURITY_SOLUTION_OWNER, type: CommentType.generatedAlert, alerts: createAlertsString( argv.ids.map((id: string) => ({ diff --git a/x-pack/plugins/cases/server/services/alerts/index.ts b/x-pack/plugins/cases/server/services/alerts/index.ts index 81afaf5363e1f..e7b331138d73c 100644 --- a/x-pack/plugins/cases/server/services/alerts/index.ts +++ b/x-pack/plugins/cases/server/services/alerts/index.ts @@ -10,10 +10,10 @@ import { isEmpty } from 'lodash'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { ElasticsearchClient, Logger } from 'kibana/server'; -import { MAX_ALERTS_PER_SUB_CASE } from '../../../common'; -import { UpdateAlertRequest } from '../../client/types'; +import { MAX_ALERTS_PER_SUB_CASE } from '../../../common/constants'; import { AlertInfo } from '../../common'; import { createCaseError } from '../../common/error'; +import { UpdateAlertRequest } from '../../client/alerts/client'; export type AlertServiceContract = PublicMethodsOf; diff --git a/x-pack/plugins/cases/server/services/attachments/index.ts b/x-pack/plugins/cases/server/services/attachments/index.ts new file mode 100644 index 0000000000000..c9b9d11a89689 --- /dev/null +++ b/x-pack/plugins/cases/server/services/attachments/index.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger, SavedObject, SavedObjectReference } from 'kibana/server'; + +import { + CommentAttributes as AttachmentAttributes, + CommentPatchAttributes as AttachmentPatchAttributes, +} from '../../../common/api'; +import { CASE_COMMENT_SAVED_OBJECT } from '../../../common/constants'; +import { ClientArgs } from '..'; + +interface GetAttachmentArgs extends ClientArgs { + attachmentId: string; +} + +interface CreateAttachmentArgs extends ClientArgs { + attributes: AttachmentAttributes; + references: SavedObjectReference[]; + id: string; +} + +interface UpdateArgs { + attachmentId: string; + updatedAttributes: AttachmentPatchAttributes; + version?: string; +} + +type UpdateAttachmentArgs = UpdateArgs & ClientArgs; + +interface BulkUpdateAttachmentArgs extends ClientArgs { + comments: UpdateArgs[]; +} + +export class AttachmentService { + constructor(private readonly log: Logger) {} + + public async get({ + unsecuredSavedObjectsClient, + attachmentId, + }: GetAttachmentArgs): Promise> { + try { + this.log.debug(`Attempting to GET attachment ${attachmentId}`); + return await unsecuredSavedObjectsClient.get( + CASE_COMMENT_SAVED_OBJECT, + attachmentId + ); + } catch (error) { + this.log.error(`Error on GET attachment ${attachmentId}: ${error}`); + throw error; + } + } + + public async delete({ unsecuredSavedObjectsClient, attachmentId }: GetAttachmentArgs) { + try { + this.log.debug(`Attempting to GET attachment ${attachmentId}`); + return await unsecuredSavedObjectsClient.delete(CASE_COMMENT_SAVED_OBJECT, attachmentId); + } catch (error) { + this.log.error(`Error on GET attachment ${attachmentId}: ${error}`); + throw error; + } + } + + public async create({ + unsecuredSavedObjectsClient, + attributes, + references, + id, + }: CreateAttachmentArgs) { + try { + this.log.debug(`Attempting to POST a new comment`); + return await unsecuredSavedObjectsClient.create( + CASE_COMMENT_SAVED_OBJECT, + attributes, + { + references, + id, + } + ); + } catch (error) { + this.log.error(`Error on POST a new comment: ${error}`); + throw error; + } + } + + public async update({ + unsecuredSavedObjectsClient, + attachmentId, + updatedAttributes, + version, + }: UpdateAttachmentArgs) { + try { + this.log.debug(`Attempting to UPDATE comment ${attachmentId}`); + return await unsecuredSavedObjectsClient.update( + CASE_COMMENT_SAVED_OBJECT, + attachmentId, + updatedAttributes, + { version } + ); + } catch (error) { + this.log.error(`Error on UPDATE comment ${attachmentId}: ${error}`); + throw error; + } + } + + public async bulkUpdate({ unsecuredSavedObjectsClient, comments }: BulkUpdateAttachmentArgs) { + try { + this.log.debug( + `Attempting to UPDATE comments ${comments.map((c) => c.attachmentId).join(', ')}` + ); + return await unsecuredSavedObjectsClient.bulkUpdate( + comments.map((c) => ({ + type: CASE_COMMENT_SAVED_OBJECT, + id: c.attachmentId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.error( + `Error on UPDATE comments ${comments.map((c) => c.attachmentId).join(', ')}: ${error}` + ); + throw error; + } + } +} diff --git a/x-pack/plugins/cases/server/services/cases/index.ts b/x-pack/plugins/cases/server/services/cases/index.ts new file mode 100644 index 0000000000000..196314a0ecbfb --- /dev/null +++ b/x-pack/plugins/cases/server/services/cases/index.ts @@ -0,0 +1,1187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { cloneDeep } from 'lodash'; +import { + KibanaRequest, + Logger, + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsBulkResponse, + SavedObjectsFindResult, +} from 'kibana/server'; + +import { AggregationContainer } from '@elastic/elasticsearch/api/types'; +import { nodeBuilder, KueryNode } from '../../../../../../src/plugins/data/common'; + +import { SecurityPluginSetup } from '../../../../security/server'; +import { + ESCaseAttributes, + CommentAttributes, + User, + SubCaseAttributes, + AssociationType, + SubCaseResponse, + CommentType, + CaseType, + CaseResponse, + caseTypeField, + CasesFindRequest, + CaseStatuses, + OWNER_FIELD, + GetCaseIdsByAlertIdAggs, +} from '../../../common/api'; +import { + defaultSortField, + flattenCaseSavedObject, + flattenSubCaseSavedObject, + groupTotalAlertsByID, + SavedObjectFindOptionsKueryNode, +} from '../../common'; +import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; +import { defaultPage, defaultPerPage } from '../../routes/api'; +import { + CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../../common/constants'; +import { ClientArgs } from '..'; +import { combineFilters } from '../../client/utils'; +import { includeFieldsRequiredForAuthentication } from '../../authorization/utils'; +import { EnsureSOAuthCallback } from '../../authorization'; + +interface GetCaseIdsByAlertIdArgs extends ClientArgs { + alertId: string; + filter?: KueryNode; +} + +interface PushedArgs { + pushed_at: string; + pushed_by: User; +} + +interface GetCaseArgs extends ClientArgs { + id: string; +} + +interface GetCasesArgs extends ClientArgs { + caseIds: string[]; +} + +interface GetSubCasesArgs extends ClientArgs { + ids: string[]; +} + +interface FindCommentsArgs { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptionsKueryNode; +} + +interface FindCaseCommentsArgs { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptionsKueryNode; + includeSubCaseComments?: boolean; +} + +interface FindSubCaseCommentsArgs { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptionsKueryNode; +} + +interface FindCasesArgs extends ClientArgs { + options?: SavedObjectFindOptionsKueryNode; +} + +interface FindSubCasesByIDArgs extends FindCasesArgs { + ids: string[]; +} + +interface FindSubCasesStatusStats { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + options: SavedObjectFindOptionsKueryNode; + ids: string[]; +} + +interface PostCaseArgs extends ClientArgs { + attributes: ESCaseAttributes; + id: string; +} + +interface CreateSubCaseArgs extends ClientArgs { + createdAt: string; + caseId: string; + createdBy: User; +} + +interface PatchCase { + caseId: string; + updatedAttributes: Partial; + version?: string; +} +type PatchCaseArgs = PatchCase & ClientArgs; + +interface PatchCasesArgs extends ClientArgs { + cases: PatchCase[]; +} + +interface PatchSubCase { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + subCaseId: string; + updatedAttributes: Partial; + version?: string; +} + +interface PatchSubCases { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + subCases: Array>; +} + +interface GetUserArgs { + request: KibanaRequest; +} + +interface SubCasesMapWithPageInfo { + subCasesMap: Map; + page: number; + perPage: number; + total: number; +} + +interface CaseCommentStats { + commentTotals: Map; + alertTotals: Map; +} + +interface FindCommentsByAssociationArgs { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + id: string | string[]; + associationType: AssociationType; + options?: SavedObjectFindOptionsKueryNode; +} + +interface Collection { + case: SavedObjectsFindResult; + subCases?: SubCaseResponse[]; +} + +interface CasesMapWithPageInfo { + casesMap: Map; + page: number; + perPage: number; + total: number; +} + +type FindCaseOptions = CasesFindRequest & SavedObjectFindOptionsKueryNode; + +interface GetTagsArgs { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + filter?: KueryNode; +} + +interface GetReportersArgs { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + filter?: KueryNode; +} + +const transformNewSubCase = ({ + createdAt, + createdBy, + owner, +}: { + createdAt: string; + createdBy: User; + owner: string; +}): SubCaseAttributes => { + return { + closed_at: null, + closed_by: null, + created_at: createdAt, + created_by: createdBy, + status: CaseStatuses.open, + updated_at: null, + updated_by: null, + owner, + }; +}; + +export class CasesService { + constructor( + private readonly log: Logger, + private readonly authentication?: SecurityPluginSetup['authc'] + ) {} + + private buildCaseIdsAggs = (size: number = 100): Record => ({ + references: { + nested: { + path: `${CASE_COMMENT_SAVED_OBJECT}.references`, + }, + aggregations: { + caseIds: { + terms: { + field: `${CASE_COMMENT_SAVED_OBJECT}.references.id`, + size, + }, + }, + }, + }, + }); + + public async getCaseIdsByAlertId({ + unsecuredSavedObjectsClient, + alertId, + filter, + }: GetCaseIdsByAlertIdArgs): Promise< + SavedObjectsFindResponse + > { + try { + this.log.debug(`Attempting to GET all cases for alert id ${alertId}`); + const combinedFilter = combineFilters([ + nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, alertId), + filter, + ]); + + let response = await unsecuredSavedObjectsClient.find< + CommentAttributes, + GetCaseIdsByAlertIdAggs + >({ + type: CASE_COMMENT_SAVED_OBJECT, + fields: includeFieldsRequiredForAuthentication(), + page: 1, + perPage: 1, + sortField: defaultSortField, + aggs: this.buildCaseIdsAggs(), + filter: combinedFilter, + }); + if (response.total > 100) { + response = await unsecuredSavedObjectsClient.find< + CommentAttributes, + GetCaseIdsByAlertIdAggs + >({ + type: CASE_COMMENT_SAVED_OBJECT, + fields: includeFieldsRequiredForAuthentication(), + page: 1, + perPage: 1, + sortField: defaultSortField, + aggs: this.buildCaseIdsAggs(response.total), + filter: combinedFilter, + }); + } + return response; + } catch (error) { + this.log.error(`Error on GET all cases for alert id ${alertId}: ${error}`); + throw error; + } + } + + /** + * Extracts the case IDs from the alert aggregation + */ + public static getCaseIDsFromAlertAggs( + result: SavedObjectsFindResponse + ): string[] { + return result.aggregations?.references.caseIds.buckets.map((b) => b.key) ?? []; + } + + /** + * Returns a map of all cases combined with their sub cases if they are collections. + */ + public async findCasesGroupedByID({ + unsecuredSavedObjectsClient, + caseOptions, + subCaseOptions, + }: { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + caseOptions: FindCaseOptions; + subCaseOptions?: SavedObjectFindOptionsKueryNode; + }): Promise { + const cases = await this.findCases({ + unsecuredSavedObjectsClient, + options: caseOptions, + }); + + const subCasesResp = ENABLE_CASE_CONNECTOR + ? await this.findSubCasesGroupByCase({ + unsecuredSavedObjectsClient, + options: subCaseOptions, + ids: cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) + .map((caseInfo) => caseInfo.id), + }) + : { subCasesMap: new Map(), page: 0, perPage: 0 }; + + const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { + const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); + + /** + * If this case is an individual add it to the return map + * If it is a collection and it has sub cases add it to the return map + * If it is a collection and it does not have sub cases, check and see if we're filtering on a status, + * if we're filtering on a status then exclude the empty collection from the results + * if we're not filtering on a status then include the empty collection (that way we can display all the collections + * when the UI isn't doing any filtering) + */ + if ( + caseInfo.attributes.type === CaseType.individual || + subCasesForCase !== undefined || + !caseOptions.status + ) { + accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); + } + return accMap; + }, new Map()); + + /** + * One potential optimization here is to get all comment stats for individual cases, parent cases, and sub cases + * in a single request. This can be done because comments that are for sub cases have a reference to both the sub case + * and the parent. The associationType field allows us to determine which type of case the comment is attached to. + * + * So we could use the ids for all the valid cases (individual cases and parents with sub cases) to grab everything. + * Once we have it we can build the maps. + * + * Currently we get all comment stats for all sub cases in one go and we get all comment stats for cases (individual and parent) + * in another request (the one below this comment). + */ + const totalCommentsForCases = await this.getCaseCommentStats({ + unsecuredSavedObjectsClient, + ids: Array.from(casesMap.keys()), + associationType: AssociationType.case, + }); + + const casesWithComments = new Map(); + for (const [id, caseInfo] of casesMap.entries()) { + casesWithComments.set( + id, + flattenCaseSavedObject({ + savedObject: caseInfo.case, + totalComment: totalCommentsForCases.commentTotals.get(id) ?? 0, + totalAlerts: totalCommentsForCases.alertTotals.get(id) ?? 0, + subCases: caseInfo.subCases, + }) + ); + } + + return { + casesMap: casesWithComments, + page: cases.page, + perPage: cases.per_page, + total: cases.total, + }; + } + + /** + * Retrieves the number of cases that exist with a given status (open, closed, etc). + * This also counts sub cases. Parent cases are excluded from the statistics. + */ + public async findCaseStatusStats({ + unsecuredSavedObjectsClient, + caseOptions, + subCaseOptions, + ensureSavedObjectsAreAuthorized, + }: { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptionsKueryNode; + ensureSavedObjectsAreAuthorized: EnsureSOAuthCallback; + subCaseOptions?: SavedObjectFindOptionsKueryNode; + }): Promise { + const casesStats = await this.findCases({ + unsecuredSavedObjectsClient, + options: { + ...caseOptions, + fields: [], + page: 1, + perPage: 1, + }, + }); + + /** + * This could be made more performant. What we're doing here is retrieving all cases + * that match the API request's filters instead of just counts. This is because we need to grab + * the ids for the parent cases that match those filters. Then we use those IDS to count how many + * sub cases those parents have to calculate the total amount of cases that are open, closed, or in-progress. + * + * Another solution would be to store ALL filterable fields on both a case and sub case. That we could do a single + * query for each type to calculate the totals using the filters. This has drawbacks though: + * + * We'd have to sync up the parent case's editable attributes with the sub case any time they were change to avoid + * them getting out of sync and causing issues when we do these types of stats aggregations. This would result in a lot + * of update requests if the user is editing their case details often. Which could potentially cause conflict failures. + * + * Another option is to prevent the ability from update the parent case's details all together once it's created. A user + * could instead modify the sub case details directly. This could be weird though because individual sub cases for the same + * parent would have different titles, tags, etc. + * + * Another potential issue with this approach is when you push a case and all its sub case information. If the sub cases + * don't have the same title and tags, we'd need to account for that as well. + */ + const cases = await this.findCases({ + unsecuredSavedObjectsClient, + options: { + ...caseOptions, + fields: includeFieldsRequiredForAuthentication([caseTypeField]), + page: 1, + perPage: casesStats.total, + }, + }); + + // make sure that the retrieved cases were correctly filtered by owner + ensureSavedObjectsAreAuthorized( + cases.saved_objects.map((caseInfo) => ({ id: caseInfo.id, owner: caseInfo.attributes.owner })) + ); + + const caseIds = cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) + .map((caseInfo) => caseInfo.id); + + let subCasesTotal = 0; + + if (ENABLE_CASE_CONNECTOR && subCaseOptions) { + subCasesTotal = await this.findSubCaseStatusStats({ + unsecuredSavedObjectsClient, + options: cloneDeep(subCaseOptions), + ids: caseIds, + }); + } + + const total = + cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.collection) + .length + subCasesTotal; + + return total; + } + + /** + * Retrieves the comments attached to a case or sub case. + */ + public async getCommentsByAssociation({ + unsecuredSavedObjectsClient, + id, + associationType, + options, + }: FindCommentsByAssociationArgs): Promise> { + if (associationType === AssociationType.subCase) { + return this.getAllSubCaseComments({ + unsecuredSavedObjectsClient, + id, + options, + }); + } else { + return this.getAllCaseComments({ + unsecuredSavedObjectsClient, + id, + options, + }); + } + } + + /** + * Returns the number of total comments and alerts for a case (or sub case) + */ + public async getCaseCommentStats({ + unsecuredSavedObjectsClient, + ids, + associationType, + }: { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + ids: string[]; + associationType: AssociationType; + }): Promise { + if (ids.length <= 0) { + return { + commentTotals: new Map(), + alertTotals: new Map(), + }; + } + + const refType = + associationType === AssociationType.case ? CASE_SAVED_OBJECT : SUB_CASE_SAVED_OBJECT; + + const allComments = await Promise.all( + ids.map((id) => + this.getCommentsByAssociation({ + unsecuredSavedObjectsClient, + associationType, + id, + options: { page: 1, perPage: 1 }, + }) + ) + ); + + const alerts = await this.getCommentsByAssociation({ + unsecuredSavedObjectsClient, + associationType, + id: ids, + options: { + filter: nodeBuilder.or([ + nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, CommentType.alert), + nodeBuilder.is( + `${CASE_COMMENT_SAVED_OBJECT}.attributes.type`, + CommentType.generatedAlert + ), + ]), + }, + }); + + const getID = (comments: SavedObjectsFindResponse) => { + return comments.saved_objects.length > 0 + ? comments.saved_objects[0].references.find((ref) => ref.type === refType)?.id + : undefined; + }; + + const groupedComments = allComments.reduce((acc, comments) => { + const id = getID(comments); + if (id) { + acc.set(id, comments.total); + } + return acc; + }, new Map()); + + const groupedAlerts = groupTotalAlertsByID({ comments: alerts }); + return { commentTotals: groupedComments, alertTotals: groupedAlerts }; + } + + /** + * Returns all the sub cases for a set of case IDs. Comment statistics are also returned. + */ + public async findSubCasesGroupByCase({ + unsecuredSavedObjectsClient, + options, + ids, + }: { + unsecuredSavedObjectsClient: SavedObjectsClientContract; + options?: SavedObjectFindOptionsKueryNode; + ids: string[]; + }): Promise { + const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { + return subCase.references.length > 0 ? subCase.references[0].id : undefined; + }; + + const emptyResponse = { + subCasesMap: new Map(), + page: 0, + perPage: 0, + total: 0, + }; + + if (!options) { + return emptyResponse; + } + + if (ids.length <= 0) { + return emptyResponse; + } + + const subCases = await this.findSubCases({ + unsecuredSavedObjectsClient, + options: { + ...options, + hasReference: ids.map((id) => { + return { + id, + type: CASE_SAVED_OBJECT, + }; + }), + }, + }); + + const subCaseComments = await this.getCaseCommentStats({ + unsecuredSavedObjectsClient, + ids: subCases.saved_objects.map((subCase) => subCase.id), + associationType: AssociationType.subCase, + }); + + const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { + const parentCaseID = getCaseID(subCase); + if (parentCaseID) { + const subCaseFromMap = accMap.get(parentCaseID); + + if (subCaseFromMap === undefined) { + const subCasesForID = [ + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, + }), + ]; + accMap.set(parentCaseID, subCasesForID); + } else { + subCaseFromMap.push( + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, + }) + ); + } + } + return accMap; + }, new Map()); + + return { subCasesMap, page: subCases.page, perPage: subCases.per_page, total: subCases.total }; + } + + /** + * Calculates the number of sub cases for a given set of options for a set of case IDs. + */ + public async findSubCaseStatusStats({ + unsecuredSavedObjectsClient, + options, + ids, + }: FindSubCasesStatusStats): Promise { + if (ids.length <= 0) { + return 0; + } + + const subCases = await this.findSubCases({ + unsecuredSavedObjectsClient, + options: { + ...options, + page: 1, + perPage: 1, + fields: [], + hasReference: ids.map((id) => { + return { + id, + type: CASE_SAVED_OBJECT, + }; + }), + }, + }); + + return subCases.total; + } + + public async createSubCase({ + unsecuredSavedObjectsClient, + createdAt, + caseId, + createdBy, + }: CreateSubCaseArgs): Promise> { + try { + this.log.debug(`Attempting to POST a new sub case`); + return unsecuredSavedObjectsClient.create( + SUB_CASE_SAVED_OBJECT, + // ENABLE_CASE_CONNECTOR: populate the owner field correctly + transformNewSubCase({ createdAt, createdBy, owner: '' }), + { + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + ], + } + ); + } catch (error) { + this.log.error(`Error on POST a new sub case for id ${caseId}: ${error}`); + throw error; + } + } + + public async getMostRecentSubCase( + unsecuredSavedObjectsClient: SavedObjectsClientContract, + caseId: string + ) { + try { + this.log.debug(`Attempting to find most recent sub case for caseID: ${caseId}`); + const subCases = await unsecuredSavedObjectsClient.find({ + perPage: 1, + sortField: 'created_at', + sortOrder: 'desc', + type: SUB_CASE_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + }); + if (subCases.saved_objects.length <= 0) { + return; + } + + return subCases.saved_objects[0]; + } catch (error) { + this.log.error(`Error finding the most recent sub case for case: ${caseId}: ${error}`); + throw error; + } + } + + public async deleteSubCase(unsecuredSavedObjectsClient: SavedObjectsClientContract, id: string) { + try { + this.log.debug(`Attempting to DELETE sub case ${id}`); + return await unsecuredSavedObjectsClient.delete(SUB_CASE_SAVED_OBJECT, id); + } catch (error) { + this.log.error(`Error on DELETE sub case ${id}: ${error}`); + throw error; + } + } + + public async deleteCase({ unsecuredSavedObjectsClient, id: caseId }: GetCaseArgs) { + try { + this.log.debug(`Attempting to DELETE case ${caseId}`); + return await unsecuredSavedObjectsClient.delete(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.error(`Error on DELETE case ${caseId}: ${error}`); + throw error; + } + } + + public async getCase({ + unsecuredSavedObjectsClient, + id: caseId, + }: GetCaseArgs): Promise> { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await unsecuredSavedObjectsClient.get(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.error(`Error on GET case ${caseId}: ${error}`); + throw error; + } + } + public async getSubCase({ + unsecuredSavedObjectsClient, + id, + }: GetCaseArgs): Promise> { + try { + this.log.debug(`Attempting to GET sub case ${id}`); + return await unsecuredSavedObjectsClient.get(SUB_CASE_SAVED_OBJECT, id); + } catch (error) { + this.log.error(`Error on GET sub case ${id}: ${error}`); + throw error; + } + } + + public async getSubCases({ + unsecuredSavedObjectsClient, + ids, + }: GetSubCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET sub cases ${ids.join(', ')}`); + return await unsecuredSavedObjectsClient.bulkGet( + ids.map((id) => ({ type: SUB_CASE_SAVED_OBJECT, id })) + ); + } catch (error) { + this.log.error(`Error on GET cases ${ids.join(', ')}: ${error}`); + throw error; + } + } + + public async getCases({ + unsecuredSavedObjectsClient, + caseIds, + }: GetCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); + return await unsecuredSavedObjectsClient.bulkGet( + caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) + ); + } catch (error) { + this.log.error(`Error on GET cases ${caseIds.join(', ')}: ${error}`); + throw error; + } + } + + public async findCases({ + unsecuredSavedObjectsClient, + options, + }: FindCasesArgs): Promise> { + try { + this.log.debug(`Attempting to find cases`); + return await unsecuredSavedObjectsClient.find({ + sortField: defaultSortField, + ...cloneDeep(options), + type: CASE_SAVED_OBJECT, + }); + } catch (error) { + this.log.error(`Error on find cases: ${error}`); + throw error; + } + } + + public async findSubCases({ + unsecuredSavedObjectsClient, + options, + }: FindCasesArgs): Promise> { + try { + this.log.debug(`Attempting to find sub cases`); + // if the page or perPage options are set then respect those instead of trying to + // grab all sub cases + if (options?.page !== undefined || options?.perPage !== undefined) { + return unsecuredSavedObjectsClient.find({ + sortField: defaultSortField, + ...cloneDeep(options), + type: SUB_CASE_SAVED_OBJECT, + }); + } + + const stats = await unsecuredSavedObjectsClient.find({ + fields: [], + page: 1, + perPage: 1, + sortField: defaultSortField, + ...cloneDeep(options), + type: SUB_CASE_SAVED_OBJECT, + }); + return unsecuredSavedObjectsClient.find({ + page: 1, + perPage: stats.total, + sortField: defaultSortField, + ...cloneDeep(options), + type: SUB_CASE_SAVED_OBJECT, + }); + } catch (error) { + this.log.error(`Error on find sub cases: ${error}`); + throw error; + } + } + + /** + * Find sub cases using a collection's ID. This would try to retrieve the maximum amount of sub cases + * by default. + * + * @param id the saved object ID of the parent collection to find sub cases for. + */ + public async findSubCasesByCaseId({ + unsecuredSavedObjectsClient, + ids, + options, + }: FindSubCasesByIDArgs): Promise> { + if (ids.length <= 0) { + return { + total: 0, + saved_objects: [], + page: options?.page ?? defaultPage, + per_page: options?.perPage ?? defaultPerPage, + }; + } + + try { + this.log.debug(`Attempting to GET sub cases for case collection id ${ids.join(', ')}`); + return this.findSubCases({ + unsecuredSavedObjectsClient, + options: { + ...options, + hasReference: ids.map((id) => ({ + type: CASE_SAVED_OBJECT, + id, + })), + }, + }); + } catch (error) { + this.log.error( + `Error on GET all sub cases for case collection id ${ids.join(', ')}: ${error}` + ); + throw error; + } + } + + private asArray(id: string | string[] | undefined): string[] { + if (id === undefined) { + return []; + } else if (Array.isArray(id)) { + return id; + } else { + return [id]; + } + } + + private async getAllComments({ + unsecuredSavedObjectsClient, + id, + options, + }: FindCommentsArgs): Promise> { + try { + this.log.debug(`Attempting to GET all comments internal for id ${JSON.stringify(id)}`); + if (options?.page !== undefined || options?.perPage !== undefined) { + return unsecuredSavedObjectsClient.find({ + type: CASE_COMMENT_SAVED_OBJECT, + sortField: defaultSortField, + ...cloneDeep(options), + }); + } + // get the total number of comments that are in ES then we'll grab them all in one go + const stats = await unsecuredSavedObjectsClient.find({ + type: CASE_COMMENT_SAVED_OBJECT, + fields: [], + page: 1, + perPage: 1, + sortField: defaultSortField, + // spread the options after so the caller can override the default behavior if they want + ...cloneDeep(options), + }); + + return unsecuredSavedObjectsClient.find({ + type: CASE_COMMENT_SAVED_OBJECT, + page: 1, + perPage: stats.total, + sortField: defaultSortField, + ...cloneDeep(options), + }); + } catch (error) { + this.log.error(`Error on GET all comments internal for ${JSON.stringify(id)}: ${error}`); + throw error; + } + } + + /** + * Default behavior is to retrieve all comments that adhere to a given filter (if one is included). + * to override this pass in the either the page or perPage options. + * + * @param includeSubCaseComments is a flag to indicate that sub case comments should be included as well, by default + * sub case comments are excluded. If the `filter` field is included in the options, it will override this behavior + */ + public async getAllCaseComments({ + unsecuredSavedObjectsClient, + id, + options, + includeSubCaseComments = false, + }: FindCaseCommentsArgs): Promise> { + try { + const refs = this.asArray(id).map((caseID) => ({ type: CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { + return { + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, + }; + } + + let filter: KueryNode | undefined; + if (!includeSubCaseComments) { + // if other filters were passed in then combine them to filter out sub case comments + const associationTypeFilter = nodeBuilder.is( + `${CASE_COMMENT_SAVED_OBJECT}.attributes.associationType`, + AssociationType.case + ); + + filter = + options?.filter != null + ? nodeBuilder.and([options.filter, associationTypeFilter]) + : associationTypeFilter; + } + + this.log.debug(`Attempting to GET all comments for case caseID ${JSON.stringify(id)}`); + return await this.getAllComments({ + unsecuredSavedObjectsClient, + id, + options: { + hasReferenceOperator: 'OR', + hasReference: refs, + filter, + ...options, + }, + }); + } catch (error) { + this.log.error(`Error on GET all comments for case ${JSON.stringify(id)}: ${error}`); + throw error; + } + } + + public async getAllSubCaseComments({ + unsecuredSavedObjectsClient, + id, + options, + }: FindSubCaseCommentsArgs): Promise> { + try { + const refs = this.asArray(id).map((caseID) => ({ type: SUB_CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { + return { + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, + }; + } + + this.log.debug(`Attempting to GET all comments for sub case caseID ${JSON.stringify(id)}`); + return await this.getAllComments({ + unsecuredSavedObjectsClient, + id, + options: { + hasReferenceOperator: 'OR', + hasReference: refs, + ...options, + }, + }); + } catch (error) { + this.log.error(`Error on GET all comments for sub case ${JSON.stringify(id)}: ${error}`); + throw error; + } + } + + public async getReporters({ + unsecuredSavedObjectsClient, + filter, + }: GetReportersArgs): Promise> { + try { + this.log.debug(`Attempting to GET all reporters`); + const firstReporters = await unsecuredSavedObjectsClient.find({ + type: CASE_SAVED_OBJECT, + fields: ['created_by', OWNER_FIELD], + page: 1, + perPage: 1, + filter: cloneDeep(filter), + }); + + return await unsecuredSavedObjectsClient.find({ + type: CASE_SAVED_OBJECT, + fields: ['created_by', OWNER_FIELD], + page: 1, + perPage: firstReporters.total, + filter: cloneDeep(filter), + }); + } catch (error) { + this.log.error(`Error on GET all reporters: ${error}`); + throw error; + } + } + + public async getTags({ + unsecuredSavedObjectsClient, + filter, + }: GetTagsArgs): Promise> { + try { + this.log.debug(`Attempting to GET all cases`); + const firstTags = await unsecuredSavedObjectsClient.find({ + type: CASE_SAVED_OBJECT, + fields: ['tags', OWNER_FIELD], + page: 1, + perPage: 1, + filter: cloneDeep(filter), + }); + + return await unsecuredSavedObjectsClient.find({ + type: CASE_SAVED_OBJECT, + fields: ['tags', OWNER_FIELD], + page: 1, + perPage: firstTags.total, + filter: cloneDeep(filter), + }); + } catch (error) { + this.log.error(`Error on GET tags: ${error}`); + throw error; + } + } + + public getUser({ request }: GetUserArgs) { + try { + this.log.debug(`Attempting to authenticate a user`); + if (this.authentication != null) { + const user = this.authentication.getCurrentUser(request); + if (!user) { + return { + username: null, + full_name: null, + email: null, + }; + } + return user; + } + return { + username: null, + full_name: null, + email: null, + }; + } catch (error) { + this.log.error(`Error on GET user: ${error}`); + throw error; + } + } + + public async postNewCase({ unsecuredSavedObjectsClient, attributes, id }: PostCaseArgs) { + try { + this.log.debug(`Attempting to POST a new case`); + return await unsecuredSavedObjectsClient.create( + CASE_SAVED_OBJECT, + attributes, + { id } + ); + } catch (error) { + this.log.error(`Error on POST a new case: ${error}`); + throw error; + } + } + + public async patchCase({ + unsecuredSavedObjectsClient, + caseId, + updatedAttributes, + version, + }: PatchCaseArgs) { + try { + this.log.debug(`Attempting to UPDATE case ${caseId}`); + return await unsecuredSavedObjectsClient.update( + CASE_SAVED_OBJECT, + caseId, + { ...updatedAttributes }, + { version } + ); + } catch (error) { + this.log.error(`Error on UPDATE case ${caseId}: ${error}`); + throw error; + } + } + + public async patchCases({ unsecuredSavedObjectsClient, cases }: PatchCasesArgs) { + try { + this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); + return await unsecuredSavedObjectsClient.bulkUpdate( + cases.map((c) => ({ + type: CASE_SAVED_OBJECT, + id: c.caseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.error(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); + throw error; + } + } + + public async patchSubCase({ + unsecuredSavedObjectsClient, + subCaseId, + updatedAttributes, + version, + }: PatchSubCase) { + try { + this.log.debug(`Attempting to UPDATE sub case ${subCaseId}`); + return await unsecuredSavedObjectsClient.update( + SUB_CASE_SAVED_OBJECT, + subCaseId, + { ...updatedAttributes }, + { version } + ); + } catch (error) { + this.log.error(`Error on UPDATE sub case ${subCaseId}: ${error}`); + throw error; + } + } + + public async patchSubCases({ unsecuredSavedObjectsClient, subCases }: PatchSubCases) { + try { + this.log.debug( + `Attempting to UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}` + ); + return await unsecuredSavedObjectsClient.bulkUpdate( + subCases.map((c) => ({ + type: SUB_CASE_SAVED_OBJECT, + id: c.subCaseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.error( + `Error on UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}: ${error}` + ); + throw error; + } + } +} diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index 0ca63bce2d1d0..8ea1c903622b7 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -5,96 +5,109 @@ * 2.0. */ -import { - Logger, - SavedObject, - SavedObjectsClientContract, - SavedObjectsFindResponse, - SavedObjectsUpdateResponse, -} from 'kibana/server'; +import { cloneDeep } from 'lodash'; +import { Logger, SavedObjectsClientContract } from 'kibana/server'; -import { ESCasesConfigureAttributes, SavedObjectFindOptions } from '../../../common'; -import { CASE_CONFIGURE_SAVED_OBJECT } from '../../saved_object_types'; +import { SavedObjectFindOptionsKueryNode } from '../../common'; +import { ESCasesConfigureAttributes } from '../../../common/api'; +import { CASE_CONFIGURE_SAVED_OBJECT } from '../../../common/constants'; interface ClientArgs { - client: SavedObjectsClientContract; + unsecuredSavedObjectsClient: SavedObjectsClientContract; } interface GetCaseConfigureArgs extends ClientArgs { - caseConfigureId: string; + configurationId: string; } interface FindCaseConfigureArgs extends ClientArgs { - options?: SavedObjectFindOptions; + options?: SavedObjectFindOptionsKueryNode; } interface PostCaseConfigureArgs extends ClientArgs { attributes: ESCasesConfigureAttributes; + id: string; } interface PatchCaseConfigureArgs extends ClientArgs { - caseConfigureId: string; + configurationId: string; updatedAttributes: Partial; } -export interface CaseConfigureServiceSetup { - delete(args: GetCaseConfigureArgs): Promise<{}>; - get(args: GetCaseConfigureArgs): Promise>; - find(args: FindCaseConfigureArgs): Promise>; - patch( - args: PatchCaseConfigureArgs - ): Promise>; - post(args: PostCaseConfigureArgs): Promise>; -} - export class CaseConfigureService { constructor(private readonly log: Logger) {} - public setup = async (): Promise => ({ - delete: async ({ client, caseConfigureId }: GetCaseConfigureArgs) => { - try { - this.log.debug(`Attempting to DELETE case configure ${caseConfigureId}`); - return await client.delete(CASE_CONFIGURE_SAVED_OBJECT, caseConfigureId); - } catch (error) { - this.log.debug(`Error on DELETE case configure ${caseConfigureId}: ${error}`); - throw error; - } - }, - get: async ({ client, caseConfigureId }: GetCaseConfigureArgs) => { - try { - this.log.debug(`Attempting to GET case configuration ${caseConfigureId}`); - return await client.get(CASE_CONFIGURE_SAVED_OBJECT, caseConfigureId); - } catch (error) { - this.log.debug(`Error on GET case configuration ${caseConfigureId}: ${error}`); - throw error; - } - }, - find: async ({ client, options }: FindCaseConfigureArgs) => { - try { - this.log.debug(`Attempting to find all case configuration`); - return await client.find({ ...options, type: CASE_CONFIGURE_SAVED_OBJECT }); - } catch (error) { - this.log.debug(`Attempting to find all case configuration`); - throw error; - } - }, - post: async ({ client, attributes }: PostCaseConfigureArgs) => { - try { - this.log.debug(`Attempting to POST a new case configuration`); - return await client.create(CASE_CONFIGURE_SAVED_OBJECT, { ...attributes }); - } catch (error) { - this.log.debug(`Error on POST a new case configuration: ${error}`); - throw error; - } - }, - patch: async ({ client, caseConfigureId, updatedAttributes }: PatchCaseConfigureArgs) => { - try { - this.log.debug(`Attempting to UPDATE case configuration ${caseConfigureId}`); - return await client.update(CASE_CONFIGURE_SAVED_OBJECT, caseConfigureId, { + + public async delete({ unsecuredSavedObjectsClient, configurationId }: GetCaseConfigureArgs) { + try { + this.log.debug(`Attempting to DELETE case configure ${configurationId}`); + return await unsecuredSavedObjectsClient.delete(CASE_CONFIGURE_SAVED_OBJECT, configurationId); + } catch (error) { + this.log.debug(`Error on DELETE case configure ${configurationId}: ${error}`); + throw error; + } + } + + public async get({ unsecuredSavedObjectsClient, configurationId }: GetCaseConfigureArgs) { + try { + this.log.debug(`Attempting to GET case configuration ${configurationId}`); + return await unsecuredSavedObjectsClient.get( + CASE_CONFIGURE_SAVED_OBJECT, + configurationId + ); + } catch (error) { + this.log.debug(`Error on GET case configuration ${configurationId}: ${error}`); + throw error; + } + } + + public async find({ unsecuredSavedObjectsClient, options }: FindCaseConfigureArgs) { + try { + this.log.debug(`Attempting to find all case configuration`); + return await unsecuredSavedObjectsClient.find({ + ...cloneDeep(options), + // Get the latest configuration + sortField: 'created_at', + sortOrder: 'desc', + type: CASE_CONFIGURE_SAVED_OBJECT, + }); + } catch (error) { + this.log.debug(`Attempting to find all case configuration`); + throw error; + } + } + + public async post({ unsecuredSavedObjectsClient, attributes, id }: PostCaseConfigureArgs) { + try { + this.log.debug(`Attempting to POST a new case configuration`); + return await unsecuredSavedObjectsClient.create( + CASE_CONFIGURE_SAVED_OBJECT, + { + ...attributes, + }, + { id } + ); + } catch (error) { + this.log.debug(`Error on POST a new case configuration: ${error}`); + throw error; + } + } + + public async patch({ + unsecuredSavedObjectsClient, + configurationId, + updatedAttributes, + }: PatchCaseConfigureArgs) { + try { + this.log.debug(`Attempting to UPDATE case configuration ${configurationId}`); + return await unsecuredSavedObjectsClient.update( + CASE_CONFIGURE_SAVED_OBJECT, + configurationId, + { ...updatedAttributes, - }); - } catch (error) { - this.log.debug(`Error on UPDATE case configuration ${caseConfigureId}: ${error}`); - throw error; - } - }, - }); + } + ); + } catch (error) { + this.log.debug(`Error on UPDATE case configuration ${configurationId}: ${error}`); + throw error; + } + } } diff --git a/x-pack/plugins/cases/server/services/connector_mappings/index.ts b/x-pack/plugins/cases/server/services/connector_mappings/index.ts index 82f37190b4ecc..e3ac5b4c55cf3 100644 --- a/x-pack/plugins/cases/server/services/connector_mappings/index.ts +++ b/x-pack/plugins/cases/server/services/connector_mappings/index.ts @@ -5,22 +5,17 @@ * 2.0. */ -import { - Logger, - SavedObject, - SavedObjectReference, - SavedObjectsClientContract, - SavedObjectsFindResponse, -} from 'kibana/server'; +import { Logger, SavedObjectReference, SavedObjectsClientContract } from 'kibana/server'; -import { ConnectorMappings, SavedObjectFindOptions } from '../../../common'; -import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../saved_object_types'; +import { ConnectorMappings } from '../../../common/api'; +import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../../common/constants'; +import { SavedObjectFindOptionsKueryNode } from '../../common'; interface ClientArgs { - client: SavedObjectsClientContract; + unsecuredSavedObjectsClient: SavedObjectsClientContract; } interface FindConnectorMappingsArgs extends ClientArgs { - options?: SavedObjectFindOptions; + options?: SavedObjectFindOptionsKueryNode; } interface PostConnectorMappingsArgs extends ClientArgs { @@ -28,33 +23,67 @@ interface PostConnectorMappingsArgs extends ClientArgs { references: SavedObjectReference[]; } -export interface ConnectorMappingsServiceSetup { - find(args: FindConnectorMappingsArgs): Promise>; - post(args: PostConnectorMappingsArgs): Promise>; +interface UpdateConnectorMappingsArgs extends ClientArgs { + mappingId: string; + attributes: Partial; + references: SavedObjectReference[]; } export class ConnectorMappingsService { constructor(private readonly log: Logger) {} - public setup = async (): Promise => ({ - find: async ({ client, options }: FindConnectorMappingsArgs) => { - try { - this.log.debug(`Attempting to find all connector mappings`); - return await client.find({ ...options, type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT }); - } catch (error) { - this.log.error(`Attempting to find all connector mappings: ${error}`); - throw error; - } - }, - post: async ({ client, attributes, references }: PostConnectorMappingsArgs) => { - try { - this.log.debug(`Attempting to POST a new connector mappings`); - return await client.create(CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, attributes, { + + public async find({ unsecuredSavedObjectsClient, options }: FindConnectorMappingsArgs) { + try { + this.log.debug(`Attempting to find all connector mappings`); + return await unsecuredSavedObjectsClient.find({ + ...options, + type: CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + }); + } catch (error) { + this.log.error(`Attempting to find all connector mappings: ${error}`); + throw error; + } + } + + public async post({ + unsecuredSavedObjectsClient, + attributes, + references, + }: PostConnectorMappingsArgs) { + try { + this.log.debug(`Attempting to POST a new connector mappings`); + return await unsecuredSavedObjectsClient.create( + CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + attributes, + { + references, + } + ); + } catch (error) { + this.log.error(`Error on POST a new connector mappings: ${error}`); + throw error; + } + } + + public async update({ + unsecuredSavedObjectsClient, + mappingId, + attributes, + references, + }: UpdateConnectorMappingsArgs) { + try { + this.log.debug(`Attempting to UPDATE connector mappings ${mappingId}`); + return await unsecuredSavedObjectsClient.update( + CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + mappingId, + attributes, + { references, - }); - } catch (error) { - this.log.error(`Error on POST a new connector mappings: ${error}`); - throw error; - } - }, - }); + } + ); + } catch (error) { + this.log.error(`Error on UPDATE connector mappings ${mappingId}: ${error}`); + throw error; + } + } } diff --git a/x-pack/plugins/cases/server/services/index.ts b/x-pack/plugins/cases/server/services/index.ts index 11b8cef6ab5a5..09895d9392441 100644 --- a/x-pack/plugins/cases/server/services/index.ts +++ b/x-pack/plugins/cases/server/services/index.ts @@ -5,1198 +5,15 @@ * 2.0. */ -import { AggregationContainer } from '@elastic/elasticsearch/api/types'; -import { - KibanaRequest, - Logger, - SavedObject, - SavedObjectsClientContract, - SavedObjectsFindResponse, - SavedObjectsUpdateResponse, - SavedObjectReference, - SavedObjectsBulkUpdateResponse, - SavedObjectsBulkResponse, - SavedObjectsFindResult, -} from 'kibana/server'; -import { nodeBuilder } from '../../../../../src/plugins/data/common'; +import { SavedObjectsClientContract } from 'kibana/server'; -import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; -import { - ENABLE_CASE_CONNECTOR, - ESCaseAttributes, - CommentAttributes, - SavedObjectFindOptions, - User, - CommentPatchAttributes, - SubCaseAttributes, - AssociationType, - SubCaseResponse, - CommentType, - CaseType, - CaseResponse, - caseTypeField, - CasesFindRequest, - GetCaseIdsByAlertIdAggs, -} from '../../common'; -import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; -import { defaultPage, defaultPerPage } from '../routes/api'; -import { - flattenCaseSavedObject, - flattenSubCaseSavedObject, - transformNewSubCase, -} from '../routes/api/utils'; -import { - CASE_SAVED_OBJECT, - CASE_COMMENT_SAVED_OBJECT, - SUB_CASE_SAVED_OBJECT, -} from '../saved_object_types'; -import { readReporters } from './reporters/read_reporters'; -import { readTags } from './tags/read_tags'; - -export { CaseConfigureService, CaseConfigureServiceSetup } from './configure'; -export { CaseUserActionService, CaseUserActionServiceSetup } from './user_actions'; -export { ConnectorMappingsService, ConnectorMappingsServiceSetup } from './connector_mappings'; +export { CasesService } from './cases'; +export { CaseConfigureService } from './configure'; +export { CaseUserActionService } from './user_actions'; +export { ConnectorMappingsService } from './connector_mappings'; export { AlertService, AlertServiceContract } from './alerts'; +export { AttachmentService } from './attachments'; export interface ClientArgs { - client: SavedObjectsClientContract; -} - -interface PushedArgs { - pushed_at: string; - pushed_by: User; -} - -interface GetCaseArgs extends ClientArgs { - id: string; -} - -interface GetCasesArgs extends ClientArgs { - caseIds: string[]; -} - -interface GetSubCasesArgs extends ClientArgs { - ids: string[]; -} - -interface FindCommentsArgs { - client: SavedObjectsClientContract; - id: string | string[]; - options?: SavedObjectFindOptions; -} - -interface FindCaseCommentsArgs { - client: SavedObjectsClientContract; - id: string | string[]; - options?: SavedObjectFindOptions; - includeSubCaseComments?: boolean; -} - -interface FindSubCaseCommentsArgs { - client: SavedObjectsClientContract; - id: string | string[]; - options?: SavedObjectFindOptions; -} - -interface FindCasesArgs extends ClientArgs { - options?: SavedObjectFindOptions; -} - -interface FindSubCasesByIDArgs extends FindCasesArgs { - ids: string[]; -} - -interface FindSubCasesStatusStats { - client: SavedObjectsClientContract; - options: SavedObjectFindOptions; - ids: string[]; -} - -interface GetCommentArgs extends ClientArgs { - commentId: string; -} - -interface GetCaseIdsByAlertIdArgs extends ClientArgs { - alertId: string; -} - -interface PostCaseArgs extends ClientArgs { - attributes: ESCaseAttributes; -} - -interface CreateSubCaseArgs extends ClientArgs { - createdAt: string; - caseId: string; - createdBy: User; -} - -interface PostCommentArgs extends ClientArgs { - attributes: CommentAttributes; - references: SavedObjectReference[]; -} - -interface PatchCase { - caseId: string; - updatedAttributes: Partial; - version?: string; -} -type PatchCaseArgs = PatchCase & ClientArgs; - -interface PatchCasesArgs extends ClientArgs { - cases: PatchCase[]; -} - -interface PatchComment { - commentId: string; - updatedAttributes: CommentPatchAttributes; - version?: string; -} - -type UpdateCommentArgs = PatchComment & ClientArgs; - -interface PatchComments extends ClientArgs { - comments: PatchComment[]; -} - -interface PatchSubCase { - client: SavedObjectsClientContract; - subCaseId: string; - updatedAttributes: Partial; - version?: string; -} - -interface PatchSubCases { - client: SavedObjectsClientContract; - subCases: Array>; -} - -interface GetUserArgs { - request: KibanaRequest; -} - -interface SubCasesMapWithPageInfo { - subCasesMap: Map; - page: number; - perPage: number; - total: number; -} - -interface CaseCommentStats { - commentTotals: Map; - alertTotals: Map; -} - -interface FindCommentsByAssociationArgs { - client: SavedObjectsClientContract; - id: string | string[]; - associationType: AssociationType; - options?: SavedObjectFindOptions; -} - -interface Collection { - case: SavedObjectsFindResult; - subCases?: SubCaseResponse[]; -} - -interface CasesMapWithPageInfo { - casesMap: Map; - page: number; - perPage: number; - total: number; -} - -type FindCaseOptions = CasesFindRequest & SavedObjectFindOptions; - -export interface CaseServiceSetup { - deleteCase(args: GetCaseArgs): Promise<{}>; - deleteComment(args: GetCommentArgs): Promise<{}>; - deleteSubCase(client: SavedObjectsClientContract, id: string): Promise<{}>; - findCases(args: FindCasesArgs): Promise>; - findSubCases(args: FindCasesArgs): Promise>; - findSubCasesByCaseId( - args: FindSubCasesByIDArgs - ): Promise>; - getAllCaseComments( - args: FindCaseCommentsArgs - ): Promise>; - getAllSubCaseComments( - args: FindSubCaseCommentsArgs - ): Promise>; - getCase(args: GetCaseArgs): Promise>; - getSubCase(args: GetCaseArgs): Promise>; - getSubCases(args: GetSubCasesArgs): Promise>; - getCases(args: GetCasesArgs): Promise>; - getComment(args: GetCommentArgs): Promise>; - getCaseIdsByAlertId(args: GetCaseIdsByAlertIdArgs): Promise; - getTags(args: ClientArgs): Promise; - getReporters(args: ClientArgs): Promise; - getUser(args: GetUserArgs): Promise; - postNewCase(args: PostCaseArgs): Promise>; - postNewComment(args: PostCommentArgs): Promise>; - patchCase(args: PatchCaseArgs): Promise>; - patchCases(args: PatchCasesArgs): Promise>; - patchComment(args: UpdateCommentArgs): Promise>; - patchComments(args: PatchComments): Promise>; - getMostRecentSubCase( - client: SavedObjectsClientContract, - caseId: string - ): Promise | undefined>; - createSubCase(args: CreateSubCaseArgs): Promise>; - patchSubCase(args: PatchSubCase): Promise>; - patchSubCases(args: PatchSubCases): Promise>; - findSubCaseStatusStats(args: FindSubCasesStatusStats): Promise; - getCommentsByAssociation( - args: FindCommentsByAssociationArgs - ): Promise>; - getCaseCommentStats(args: { - client: SavedObjectsClientContract; - ids: string[]; - associationType: AssociationType; - }): Promise; - findSubCasesGroupByCase(args: { - client: SavedObjectsClientContract; - options?: SavedObjectFindOptions; - ids: string[]; - }): Promise; - findCaseStatusStats(args: { - client: SavedObjectsClientContract; - caseOptions: SavedObjectFindOptions; - subCaseOptions?: SavedObjectFindOptions; - }): Promise; - findCasesGroupedByID(args: { - client: SavedObjectsClientContract; - caseOptions: SavedObjectFindOptions; - subCaseOptions?: SavedObjectFindOptions; - }): Promise; -} - -export class CaseService implements CaseServiceSetup { - constructor( - private readonly log: Logger, - private readonly authentication?: SecurityPluginSetup['authc'] - ) {} - - /** - * Returns a map of all cases combined with their sub cases if they are collections. - */ - public async findCasesGroupedByID({ - client, - caseOptions, - subCaseOptions, - }: { - client: SavedObjectsClientContract; - caseOptions: FindCaseOptions; - subCaseOptions?: SavedObjectFindOptions; - }): Promise { - const cases = await this.findCases({ - client, - options: caseOptions, - }); - - const subCasesResp = ENABLE_CASE_CONNECTOR - ? await this.findSubCasesGroupByCase({ - client, - options: subCaseOptions, - ids: cases.saved_objects - .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) - .map((caseInfo) => caseInfo.id), - }) - : { subCasesMap: new Map(), page: 0, perPage: 0 }; - - const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { - const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); - - /** - * If this case is an individual add it to the return map - * If it is a collection and it has sub cases add it to the return map - * If it is a collection and it does not have sub cases, check and see if we're filtering on a status, - * if we're filtering on a status then exclude the empty collection from the results - * if we're not filtering on a status then include the empty collection (that way we can display all the collections - * when the UI isn't doing any filtering) - */ - if ( - caseInfo.attributes.type === CaseType.individual || - subCasesForCase !== undefined || - !caseOptions.status - ) { - accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); - } - return accMap; - }, new Map()); - - /** - * One potential optimization here is to get all comment stats for individual cases, parent cases, and sub cases - * in a single request. This can be done because comments that are for sub cases have a reference to both the sub case - * and the parent. The associationType field allows us to determine which type of case the comment is attached to. - * - * So we could use the ids for all the valid cases (individual cases and parents with sub cases) to grab everything. - * Once we have it we can build the maps. - * - * Currently we get all comment stats for all sub cases in one go and we get all comment stats for cases (individual and parent) - * in another request (the one below this comment). - */ - const totalCommentsForCases = await this.getCaseCommentStats({ - client, - ids: Array.from(casesMap.keys()), - associationType: AssociationType.case, - }); - - const casesWithComments = new Map(); - for (const [id, caseInfo] of casesMap.entries()) { - casesWithComments.set( - id, - flattenCaseSavedObject({ - savedObject: caseInfo.case, - totalComment: totalCommentsForCases.commentTotals.get(id) ?? 0, - totalAlerts: totalCommentsForCases.alertTotals.get(id) ?? 0, - subCases: caseInfo.subCases, - }) - ); - } - - return { - casesMap: casesWithComments, - page: cases.page, - perPage: cases.per_page, - total: cases.total, - }; - } - - /** - * Retrieves the number of cases that exist with a given status (open, closed, etc). - * This also counts sub cases. Parent cases are excluded from the statistics. - */ - public async findCaseStatusStats({ - client, - caseOptions, - subCaseOptions, - }: { - client: SavedObjectsClientContract; - caseOptions: SavedObjectFindOptions; - subCaseOptions?: SavedObjectFindOptions; - }): Promise { - const casesStats = await this.findCases({ - client, - options: { - ...caseOptions, - fields: [], - page: 1, - perPage: 1, - }, - }); - - /** - * This could be made more performant. What we're doing here is retrieving all cases - * that match the API request's filters instead of just counts. This is because we need to grab - * the ids for the parent cases that match those filters. Then we use those IDS to count how many - * sub cases those parents have to calculate the total amount of cases that are open, closed, or in-progress. - * - * Another solution would be to store ALL filterable fields on both a case and sub case. That we could do a single - * query for each type to calculate the totals using the filters. This has drawbacks though: - * - * We'd have to sync up the parent case's editable attributes with the sub case any time they were change to avoid - * them getting out of sync and causing issues when we do these types of stats aggregations. This would result in a lot - * of update requests if the user is editing their case details often. Which could potentially cause conflict failures. - * - * Another option is to prevent the ability from update the parent case's details all together once it's created. A user - * could instead modify the sub case details directly. This could be weird though because individual sub cases for the same - * parent would have different titles, tags, etc. - * - * Another potential issue with this approach is when you push a case and all its sub case information. If the sub cases - * don't have the same title and tags, we'd need to account for that as well. - */ - const cases = await this.findCases({ - client, - options: { - ...caseOptions, - fields: [caseTypeField], - page: 1, - perPage: casesStats.total, - }, - }); - - const caseIds = cases.saved_objects - .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) - .map((caseInfo) => caseInfo.id); - - let subCasesTotal = 0; - - if (ENABLE_CASE_CONNECTOR && subCaseOptions) { - subCasesTotal = await this.findSubCaseStatusStats({ - client, - options: subCaseOptions, - ids: caseIds, - }); - } - - const total = - cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.collection) - .length + subCasesTotal; - - return total; - } - - /** - * Retrieves the comments attached to a case or sub case. - */ - public async getCommentsByAssociation({ - client, - id, - associationType, - options, - }: FindCommentsByAssociationArgs): Promise> { - if (associationType === AssociationType.subCase) { - return this.getAllSubCaseComments({ - client, - id, - options, - }); - } else { - return this.getAllCaseComments({ - client, - id, - options, - }); - } - } - - /** - * Returns the number of total comments and alerts for a case (or sub case) - */ - public async getCaseCommentStats({ - client, - ids, - associationType, - }: { - client: SavedObjectsClientContract; - ids: string[]; - associationType: AssociationType; - }): Promise { - if (ids.length <= 0) { - return { - commentTotals: new Map(), - alertTotals: new Map(), - }; - } - - const refType = - associationType === AssociationType.case ? CASE_SAVED_OBJECT : SUB_CASE_SAVED_OBJECT; - - const allComments = await Promise.all( - ids.map((id) => - this.getCommentsByAssociation({ - client, - associationType, - id, - options: { page: 1, perPage: 1 }, - }) - ) - ); - - const alerts = await this.getCommentsByAssociation({ - client, - associationType, - id: ids, - options: { - filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert})`, - }, - }); - - const getID = (comments: SavedObjectsFindResponse) => { - return comments.saved_objects.length > 0 - ? comments.saved_objects[0].references.find((ref) => ref.type === refType)?.id - : undefined; - }; - - const groupedComments = allComments.reduce((acc, comments) => { - const id = getID(comments); - if (id) { - acc.set(id, comments.total); - } - return acc; - }, new Map()); - - const groupedAlerts = groupTotalAlertsByID({ comments: alerts }); - return { commentTotals: groupedComments, alertTotals: groupedAlerts }; - } - - /** - * Returns all the sub cases for a set of case IDs. Comment statistics are also returned. - */ - public async findSubCasesGroupByCase({ - client, - options, - ids, - }: { - client: SavedObjectsClientContract; - options?: SavedObjectFindOptions; - ids: string[]; - }): Promise { - const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { - return subCase.references.length > 0 ? subCase.references[0].id : undefined; - }; - - const emptyResponse = { - subCasesMap: new Map(), - page: 0, - perPage: 0, - total: 0, - }; - - if (!options) { - return emptyResponse; - } - - if (ids.length <= 0) { - return emptyResponse; - } - - const subCases = await this.findSubCases({ - client, - options: { - ...options, - hasReference: ids.map((id) => { - return { - id, - type: CASE_SAVED_OBJECT, - }; - }), - }, - }); - - const subCaseComments = await this.getCaseCommentStats({ - client, - ids: subCases.saved_objects.map((subCase) => subCase.id), - associationType: AssociationType.subCase, - }); - - const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { - const parentCaseID = getCaseID(subCase); - if (parentCaseID) { - const subCaseFromMap = accMap.get(parentCaseID); - - if (subCaseFromMap === undefined) { - const subCasesForID = [ - flattenSubCaseSavedObject({ - savedObject: subCase, - totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, - totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, - }), - ]; - accMap.set(parentCaseID, subCasesForID); - } else { - subCaseFromMap.push( - flattenSubCaseSavedObject({ - savedObject: subCase, - totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, - totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, - }) - ); - } - } - return accMap; - }, new Map()); - - return { subCasesMap, page: subCases.page, perPage: subCases.per_page, total: subCases.total }; - } - - /** - * Calculates the number of sub cases for a given set of options for a set of case IDs. - */ - public async findSubCaseStatusStats({ - client, - options, - ids, - }: FindSubCasesStatusStats): Promise { - if (ids.length <= 0) { - return 0; - } - - const subCases = await this.findSubCases({ - client, - options: { - ...options, - page: 1, - perPage: 1, - fields: [], - hasReference: ids.map((id) => { - return { - id, - type: CASE_SAVED_OBJECT, - }; - }), - }, - }); - - return subCases.total; - } - - public async createSubCase({ - client, - createdAt, - caseId, - createdBy, - }: CreateSubCaseArgs): Promise> { - try { - this.log.debug(`Attempting to POST a new sub case`); - return client.create(SUB_CASE_SAVED_OBJECT, transformNewSubCase({ createdAt, createdBy }), { - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: caseId, - }, - ], - }); - } catch (error) { - this.log.error(`Error on POST a new sub case for id ${caseId}: ${error}`); - throw error; - } - } - - public async getMostRecentSubCase(client: SavedObjectsClientContract, caseId: string) { - try { - this.log.debug(`Attempting to find most recent sub case for caseID: ${caseId}`); - const subCases: SavedObjectsFindResponse = await client.find({ - perPage: 1, - sortField: 'created_at', - sortOrder: 'desc', - type: SUB_CASE_SAVED_OBJECT, - hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, - }); - if (subCases.saved_objects.length <= 0) { - return; - } - - return subCases.saved_objects[0]; - } catch (error) { - this.log.error(`Error finding the most recent sub case for case: ${caseId}: ${error}`); - throw error; - } - } - - public async deleteSubCase(client: SavedObjectsClientContract, id: string) { - try { - this.log.debug(`Attempting to DELETE sub case ${id}`); - return await client.delete(SUB_CASE_SAVED_OBJECT, id); - } catch (error) { - this.log.error(`Error on DELETE sub case ${id}: ${error}`); - throw error; - } - } - - public async deleteCase({ client, id: caseId }: GetCaseArgs) { - try { - this.log.debug(`Attempting to DELETE case ${caseId}`); - return await client.delete(CASE_SAVED_OBJECT, caseId); - } catch (error) { - this.log.error(`Error on DELETE case ${caseId}: ${error}`); - throw error; - } - } - public async deleteComment({ client, commentId }: GetCommentArgs) { - try { - this.log.debug(`Attempting to GET comment ${commentId}`); - return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); - } catch (error) { - this.log.error(`Error on GET comment ${commentId}: ${error}`); - throw error; - } - } - public async getCase({ - client, - id: caseId, - }: GetCaseArgs): Promise> { - try { - this.log.debug(`Attempting to GET case ${caseId}`); - return await client.get(CASE_SAVED_OBJECT, caseId); - } catch (error) { - this.log.error(`Error on GET case ${caseId}: ${error}`); - throw error; - } - } - public async getSubCase({ client, id }: GetCaseArgs): Promise> { - try { - this.log.debug(`Attempting to GET sub case ${id}`); - return await client.get(SUB_CASE_SAVED_OBJECT, id); - } catch (error) { - this.log.error(`Error on GET sub case ${id}: ${error}`); - throw error; - } - } - - public async getSubCases({ - client, - ids, - }: GetSubCasesArgs): Promise> { - try { - this.log.debug(`Attempting to GET sub cases ${ids.join(', ')}`); - return await client.bulkGet(ids.map((id) => ({ type: SUB_CASE_SAVED_OBJECT, id }))); - } catch (error) { - this.log.error(`Error on GET cases ${ids.join(', ')}: ${error}`); - throw error; - } - } - - public async getCases({ - client, - caseIds, - }: GetCasesArgs): Promise> { - try { - this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); - return await client.bulkGet( - caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) - ); - } catch (error) { - this.log.error(`Error on GET cases ${caseIds.join(', ')}: ${error}`); - throw error; - } - } - public async getComment({ - client, - commentId, - }: GetCommentArgs): Promise> { - try { - this.log.debug(`Attempting to GET comment ${commentId}`); - return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); - } catch (error) { - this.log.error(`Error on GET comment ${commentId}: ${error}`); - throw error; - } - } - - public async findCases({ - client, - options, - }: FindCasesArgs): Promise> { - try { - this.log.debug(`Attempting to find cases`); - return await client.find({ - sortField: defaultSortField, - ...options, - type: CASE_SAVED_OBJECT, - }); - } catch (error) { - this.log.error(`Error on find cases: ${error}`); - throw error; - } - } - - public async findSubCases({ - client, - options, - }: FindCasesArgs): Promise> { - try { - this.log.debug(`Attempting to find sub cases`); - // if the page or perPage options are set then respect those instead of trying to - // grab all sub cases - if (options?.page !== undefined || options?.perPage !== undefined) { - return client.find({ - sortField: defaultSortField, - ...options, - type: SUB_CASE_SAVED_OBJECT, - }); - } - - const stats = await client.find({ - fields: [], - page: 1, - perPage: 1, - sortField: defaultSortField, - ...options, - type: SUB_CASE_SAVED_OBJECT, - }); - return client.find({ - page: 1, - perPage: stats.total, - sortField: defaultSortField, - ...options, - type: SUB_CASE_SAVED_OBJECT, - }); - } catch (error) { - this.log.error(`Error on find sub cases: ${error}`); - throw error; - } - } - - /** - * Find sub cases using a collection's ID. This would try to retrieve the maximum amount of sub cases - * by default. - * - * @param id the saved object ID of the parent collection to find sub cases for. - */ - public async findSubCasesByCaseId({ - client, - ids, - options, - }: FindSubCasesByIDArgs): Promise> { - if (ids.length <= 0) { - return { - total: 0, - saved_objects: [], - page: options?.page ?? defaultPage, - per_page: options?.perPage ?? defaultPerPage, - }; - } - - try { - this.log.debug(`Attempting to GET sub cases for case collection id ${ids.join(', ')}`); - return this.findSubCases({ - client, - options: { - ...options, - hasReference: ids.map((id) => ({ - type: CASE_SAVED_OBJECT, - id, - })), - }, - }); - } catch (error) { - this.log.error( - `Error on GET all sub cases for case collection id ${ids.join(', ')}: ${error}` - ); - throw error; - } - } - - private asArray(id: string | string[] | undefined): string[] { - if (id === undefined) { - return []; - } else if (Array.isArray(id)) { - return id; - } else { - return [id]; - } - } - - private async getAllComments({ - client, - id, - options, - }: FindCommentsArgs): Promise> { - try { - this.log.debug(`Attempting to GET all comments for id ${JSON.stringify(id)}`); - if (options?.page !== undefined || options?.perPage !== undefined) { - return client.find({ - type: CASE_COMMENT_SAVED_OBJECT, - sortField: defaultSortField, - ...options, - }); - } - // get the total number of comments that are in ES then we'll grab them all in one go - const stats = await client.find({ - type: CASE_COMMENT_SAVED_OBJECT, - fields: [], - page: 1, - perPage: 1, - sortField: defaultSortField, - // spread the options after so the caller can override the default behavior if they want - ...options, - }); - - return client.find({ - type: CASE_COMMENT_SAVED_OBJECT, - page: 1, - perPage: stats.total, - sortField: defaultSortField, - ...options, - }); - } catch (error) { - this.log.error(`Error on GET all comments for ${JSON.stringify(id)}: ${error}`); - throw error; - } - } - - private buildCaseIdsAggs = (size: number = 100): Record => ({ - references: { - nested: { - path: `${CASE_COMMENT_SAVED_OBJECT}.references`, - }, - aggregations: { - caseIds: { - terms: { - field: `${CASE_COMMENT_SAVED_OBJECT}.references.id`, - size, - }, - }, - }, - }, - }); - - public async getCaseIdsByAlertId({ - client, - alertId, - }: GetCaseIdsByAlertIdArgs): Promise { - try { - this.log.debug(`Attempting to GET all cases for alert id ${alertId}`); - - let response = await client.find({ - type: CASE_COMMENT_SAVED_OBJECT, - fields: [], - page: 1, - perPage: 1, - sortField: defaultSortField, - aggs: this.buildCaseIdsAggs(), - filter: nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, alertId), - }); - if (response.total > 100) { - response = await client.find({ - type: CASE_COMMENT_SAVED_OBJECT, - fields: [], - page: 1, - perPage: 1, - sortField: defaultSortField, - aggs: this.buildCaseIdsAggs(response.total), - filter: nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, alertId), - }); - } - return response.aggregations?.references.caseIds.buckets.map((b) => b.key) ?? []; - } catch (error) { - this.log.error(`Error on GET all cases for alert id ${alertId}: ${error}`); - throw error; - } - } - - /** - * Default behavior is to retrieve all comments that adhere to a given filter (if one is included). - * to override this pass in the either the page or perPage options. - * - * @param includeSubCaseComments is a flag to indicate that sub case comments should be included as well, by default - * sub case comments are excluded. If the `filter` field is included in the options, it will override this behavior - */ - public async getAllCaseComments({ - client, - id, - options, - includeSubCaseComments = false, - }: FindCaseCommentsArgs): Promise> { - try { - const refs = this.asArray(id).map((caseID) => ({ type: CASE_SAVED_OBJECT, id: caseID })); - if (refs.length <= 0) { - return { - saved_objects: [], - total: 0, - per_page: options?.perPage ?? defaultPerPage, - page: options?.page ?? defaultPage, - }; - } - - let filter: string | undefined; - if (!includeSubCaseComments) { - // if other filters were passed in then combine them to filter out sub case comments - filter = combineFilters( - [ - options?.filter ?? '', - `${CASE_COMMENT_SAVED_OBJECT}.attributes.associationType: ${AssociationType.case}`, - ], - 'AND' - ); - } - - this.log.debug(`Attempting to GET all comments for case caseID ${JSON.stringify(id)}`); - return this.getAllComments({ - client, - id, - options: { - hasReferenceOperator: 'OR', - hasReference: refs, - filter, - ...options, - }, - }); - } catch (error) { - this.log.error(`Error on GET all comments for case ${JSON.stringify(id)}: ${error}`); - throw error; - } - } - - public async getAllSubCaseComments({ - client, - id, - options, - }: FindSubCaseCommentsArgs): Promise> { - try { - const refs = this.asArray(id).map((caseID) => ({ type: SUB_CASE_SAVED_OBJECT, id: caseID })); - if (refs.length <= 0) { - return { - saved_objects: [], - total: 0, - per_page: options?.perPage ?? defaultPerPage, - page: options?.page ?? defaultPage, - }; - } - - this.log.debug(`Attempting to GET all comments for sub case caseID ${JSON.stringify(id)}`); - return this.getAllComments({ - client, - id, - options: { - hasReferenceOperator: 'OR', - hasReference: refs, - ...options, - }, - }); - } catch (error) { - this.log.error(`Error on GET all comments for sub case ${JSON.stringify(id)}: ${error}`); - throw error; - } - } - - public async getReporters({ client }: ClientArgs) { - try { - this.log.debug(`Attempting to GET all reporters`); - return await readReporters({ client }); - } catch (error) { - this.log.error(`Error on GET all reporters: ${error}`); - throw error; - } - } - public async getTags({ client }: ClientArgs) { - try { - this.log.debug(`Attempting to GET all cases`); - return await readTags({ client }); - } catch (error) { - this.log.error(`Error on GET cases: ${error}`); - throw error; - } - } - - public async getUser({ request }: GetUserArgs) { - try { - this.log.debug(`Attempting to authenticate a user`); - if (this.authentication != null) { - const user = this.authentication.getCurrentUser(request); - if (!user) { - return { - username: null, - full_name: null, - email: null, - }; - } - return user; - } - return { - username: null, - full_name: null, - email: null, - }; - } catch (error) { - this.log.error(`Error on GET cases: ${error}`); - throw error; - } - } - public async postNewCase({ client, attributes }: PostCaseArgs) { - try { - this.log.debug(`Attempting to POST a new case`); - return await client.create(CASE_SAVED_OBJECT, { ...attributes }); - } catch (error) { - this.log.error(`Error on POST a new case: ${error}`); - throw error; - } - } - public async postNewComment({ client, attributes, references }: PostCommentArgs) { - try { - this.log.debug(`Attempting to POST a new comment`); - return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); - } catch (error) { - this.log.error(`Error on POST a new comment: ${error}`); - throw error; - } - } - public async patchCase({ client, caseId, updatedAttributes, version }: PatchCaseArgs) { - try { - this.log.debug(`Attempting to UPDATE case ${caseId}`); - return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }, { version }); - } catch (error) { - this.log.error(`Error on UPDATE case ${caseId}: ${error}`); - throw error; - } - } - public async patchCases({ client, cases }: PatchCasesArgs) { - try { - this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); - return await client.bulkUpdate( - cases.map((c) => ({ - type: CASE_SAVED_OBJECT, - id: c.caseId, - attributes: c.updatedAttributes, - version: c.version, - })) - ); - } catch (error) { - this.log.error(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); - throw error; - } - } - public async patchComment({ client, commentId, updatedAttributes, version }: UpdateCommentArgs) { - try { - this.log.debug(`Attempting to UPDATE comment ${commentId}`); - return await client.update( - CASE_COMMENT_SAVED_OBJECT, - commentId, - { - ...updatedAttributes, - }, - { version } - ); - } catch (error) { - this.log.error(`Error on UPDATE comment ${commentId}: ${error}`); - throw error; - } - } - public async patchComments({ client, comments }: PatchComments) { - try { - this.log.debug( - `Attempting to UPDATE comments ${comments.map((c) => c.commentId).join(', ')}` - ); - return await client.bulkUpdate( - comments.map((c) => ({ - type: CASE_COMMENT_SAVED_OBJECT, - id: c.commentId, - attributes: c.updatedAttributes, - version: c.version, - })) - ); - } catch (error) { - this.log.error( - `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` - ); - throw error; - } - } - public async patchSubCase({ client, subCaseId, updatedAttributes, version }: PatchSubCase) { - try { - this.log.debug(`Attempting to UPDATE sub case ${subCaseId}`); - return await client.update( - SUB_CASE_SAVED_OBJECT, - subCaseId, - { ...updatedAttributes }, - { version } - ); - } catch (error) { - this.log.error(`Error on UPDATE sub case ${subCaseId}: ${error}`); - throw error; - } - } - - public async patchSubCases({ client, subCases }: PatchSubCases) { - try { - this.log.debug( - `Attempting to UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}` - ); - return await client.bulkUpdate( - subCases.map((c) => ({ - type: SUB_CASE_SAVED_OBJECT, - id: c.subCaseId, - attributes: c.updatedAttributes, - version: c.version, - })) - ); - } catch (error) { - this.log.error( - `Error on UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}: ${error}` - ); - throw error; - } - } + unsecuredSavedObjectsClient: SavedObjectsClientContract; } diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index d67a297508b14..ce9aec942220a 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -5,75 +5,107 @@ * 2.0. */ +import { PublicMethodsOf } from '@kbn/utility-types'; import { AlertServiceContract, - CaseConfigureServiceSetup, - CaseServiceSetup, - CaseUserActionServiceSetup, - ConnectorMappingsServiceSetup, + CaseConfigureService, + CasesService, + CaseUserActionService, + ConnectorMappingsService, + AttachmentService, } from '.'; -export type CaseServiceMock = jest.Mocked; -export type CaseConfigureServiceMock = jest.Mocked; -export type ConnectorMappingsServiceMock = jest.Mocked; -export type CaseUserActionServiceMock = jest.Mocked; +export type CaseServiceMock = jest.Mocked; +export type CaseConfigureServiceMock = jest.Mocked; +export type ConnectorMappingsServiceMock = jest.Mocked; +export type CaseUserActionServiceMock = jest.Mocked; export type AlertServiceMock = jest.Mocked; +export type AttachmentServiceMock = jest.Mocked; -export const createCaseServiceMock = (): CaseServiceMock => ({ - createSubCase: jest.fn(), - deleteCase: jest.fn(), - deleteComment: jest.fn(), - deleteSubCase: jest.fn(), - findCases: jest.fn(), - findSubCases: jest.fn(), - findSubCasesByCaseId: jest.fn(), - getAllCaseComments: jest.fn(), - getAllSubCaseComments: jest.fn(), - getCase: jest.fn(), - getCases: jest.fn(), - getCaseIdsByAlertId: jest.fn(), - getComment: jest.fn(), - getMostRecentSubCase: jest.fn(), - getSubCase: jest.fn(), - getSubCases: jest.fn(), - getTags: jest.fn(), - getReporters: jest.fn(), - getUser: jest.fn(), - postNewCase: jest.fn(), - postNewComment: jest.fn(), - patchCase: jest.fn(), - patchCases: jest.fn(), - patchComment: jest.fn(), - patchComments: jest.fn(), - patchSubCase: jest.fn(), - patchSubCases: jest.fn(), - findSubCaseStatusStats: jest.fn(), - getCommentsByAssociation: jest.fn(), - getCaseCommentStats: jest.fn(), - findSubCasesGroupByCase: jest.fn(), - findCaseStatusStats: jest.fn(), - findCasesGroupedByID: jest.fn(), -}); +export const createCaseServiceMock = (): CaseServiceMock => { + const service: PublicMethodsOf = { + createSubCase: jest.fn(), + deleteCase: jest.fn(), + deleteSubCase: jest.fn(), + findCases: jest.fn(), + findSubCases: jest.fn(), + findSubCasesByCaseId: jest.fn(), + getAllCaseComments: jest.fn(), + getAllSubCaseComments: jest.fn(), + getCase: jest.fn(), + getCases: jest.fn(), + getCaseIdsByAlertId: jest.fn(), + getMostRecentSubCase: jest.fn(), + getSubCase: jest.fn(), + getSubCases: jest.fn(), + getTags: jest.fn(), + getReporters: jest.fn(), + getUser: jest.fn(), + postNewCase: jest.fn(), + patchCase: jest.fn(), + patchCases: jest.fn(), + patchSubCase: jest.fn(), + patchSubCases: jest.fn(), + findSubCaseStatusStats: jest.fn(), + getCommentsByAssociation: jest.fn(), + getCaseCommentStats: jest.fn(), + findSubCasesGroupByCase: jest.fn(), + findCaseStatusStats: jest.fn(), + findCasesGroupedByID: jest.fn(), + }; -export const createConfigureServiceMock = (): CaseConfigureServiceMock => ({ - delete: jest.fn(), - get: jest.fn(), - find: jest.fn(), - patch: jest.fn(), - post: jest.fn(), -}); + // the cast here is required because jest.Mocked tries to include private members and would throw an error + return (service as unknown) as CaseServiceMock; +}; -export const connectorMappingsServiceMock = (): ConnectorMappingsServiceMock => ({ - find: jest.fn(), - post: jest.fn(), -}); +export const createConfigureServiceMock = (): CaseConfigureServiceMock => { + const service: PublicMethodsOf = { + delete: jest.fn(), + get: jest.fn(), + find: jest.fn(), + patch: jest.fn(), + post: jest.fn(), + }; -export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ - getUserActions: jest.fn(), - postUserActions: jest.fn(), -}); + // the cast here is required because jest.Mocked tries to include private members and would throw an error + return (service as unknown) as CaseConfigureServiceMock; +}; + +export const connectorMappingsServiceMock = (): ConnectorMappingsServiceMock => { + const service: PublicMethodsOf = { + find: jest.fn(), + post: jest.fn(), + update: jest.fn(), + }; + + // the cast here is required because jest.Mocked tries to include private members and would throw an error + return (service as unknown) as ConnectorMappingsServiceMock; +}; + +export const createUserActionServiceMock = (): CaseUserActionServiceMock => { + const service: PublicMethodsOf = { + getAll: jest.fn(), + bulkCreate: jest.fn(), + }; + + // the cast here is required because jest.Mocked tries to include private members and would throw an error + return (service as unknown) as CaseUserActionServiceMock; +}; export const createAlertServiceMock = (): AlertServiceMock => ({ updateAlertsStatus: jest.fn(), getAlerts: jest.fn(), }); + +export const createAttachmentServiceMock = (): AttachmentServiceMock => { + const service: PublicMethodsOf = { + get: jest.fn(), + delete: jest.fn(), + create: jest.fn(), + update: jest.fn(), + bulkUpdate: jest.fn(), + }; + + // the cast here is required because jest.Mocked tries to include private members and would throw an error + return (service as unknown) as AttachmentServiceMock; +}; diff --git a/x-pack/plugins/cases/server/services/reporters/read_reporters.ts b/x-pack/plugins/cases/server/services/reporters/read_reporters.ts deleted file mode 100644 index b47fa185ff78e..0000000000000 --- a/x-pack/plugins/cases/server/services/reporters/read_reporters.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; - -import { CaseAttributes, User } from '../../../common'; -import { CASE_SAVED_OBJECT } from '../../saved_object_types'; - -export const convertToReporters = (caseObjects: Array>): User[] => - caseObjects.reduce((accum, caseObj) => { - if ( - caseObj && - caseObj.attributes && - caseObj.attributes.created_by && - caseObj.attributes.created_by.username && - !accum.some((item) => item.username === caseObj.attributes.created_by.username) - ) { - return [...accum, caseObj.attributes.created_by]; - } else { - return accum; - } - }, []); - -export const readReporters = async ({ - client, -}: { - client: SavedObjectsClientContract; - perPage?: number; -}): Promise => { - const firstReporters = await client.find({ - type: CASE_SAVED_OBJECT, - fields: ['created_by'], - page: 1, - perPage: 1, - }); - const reporters = await client.find({ - type: CASE_SAVED_OBJECT, - fields: ['created_by'], - page: 1, - perPage: firstReporters.total, - }); - return convertToReporters(reporters.saved_objects); -}; diff --git a/x-pack/plugins/cases/server/services/tags/read_tags.ts b/x-pack/plugins/cases/server/services/tags/read_tags.ts deleted file mode 100644 index a00b0b6f26fb7..0000000000000 --- a/x-pack/plugins/cases/server/services/tags/read_tags.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; - -import { CaseAttributes } from '../../../common'; -import { CASE_SAVED_OBJECT } from '../../saved_object_types'; - -export const convertToTags = (tagObjects: Array>): string[] => - tagObjects.reduce((accum, tagObj) => { - if (tagObj && tagObj.attributes && tagObj.attributes.tags) { - return [...accum, ...tagObj.attributes.tags]; - } else { - return accum; - } - }, []); - -export const convertTagsToSet = (tagObjects: Array>): Set => { - return new Set(convertToTags(tagObjects)); -}; - -// Note: This is doing an in-memory aggregation of the tags by calling each of the case -// records in batches of this const setting and uses the fields to try to get the least -// amount of data per record back. If saved objects at some point supports aggregations -// then this should be replaced with a an aggregation call. -// Ref: https://www.elastic.co/guide/en/kibana/master/saved-objects-api.html -export const readTags = async ({ - client, -}: { - client: SavedObjectsClientContract; - perPage?: number; -}): Promise => { - const tags = await readRawTags({ client }); - return tags; -}; - -export const readRawTags = async ({ - client, -}: { - client: SavedObjectsClientContract; -}): Promise => { - const firstTags = await client.find({ - type: CASE_SAVED_OBJECT, - fields: ['tags'], - page: 1, - perPage: 1, - }); - const tags = await client.find({ - type: CASE_SAVED_OBJECT, - fields: ['tags'], - page: 1, - perPage: firstTags.total, - }); - - return Array.from(convertTagsToSet(tags.saved_objects)); -}; diff --git a/x-pack/plugins/cases/server/services/user_actions/helpers.ts b/x-pack/plugins/cases/server/services/user_actions/helpers.ts index be32717039d9d..664a9041491a1 100644 --- a/x-pack/plugins/cases/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/cases/server/services/user_actions/helpers.ts @@ -17,17 +17,16 @@ import { User, UserActionFieldType, SubCaseAttributes, -} from '../../../common'; -import { - isTwoArraysDifference, - transformESConnectorToCaseConnector, -} from '../../routes/api/cases/helpers'; + OWNER_FIELD, +} from '../../../common/api'; +import { isTwoArraysDifference } from '../../client/utils'; import { UserActionItem } from '.'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT, -} from '../../saved_object_types'; +} from '../../../common/constants'; +import { transformESConnectorToCaseConnector } from '../../common'; export const transformNewUserAction = ({ actionField, @@ -36,6 +35,7 @@ export const transformNewUserAction = ({ email, // eslint-disable-next-line @typescript-eslint/naming-convention full_name, + owner, newValue = null, oldValue = null, username, @@ -43,6 +43,7 @@ export const transformNewUserAction = ({ actionField: UserActionField; action: UserAction; actionAt: string; + owner: string; email?: string | null; full_name?: string | null; newValue?: string | null; @@ -55,6 +56,7 @@ export const transformNewUserAction = ({ action_by: { email, full_name, username }, new_value: newValue, old_value: oldValue, + owner, }); interface BuildCaseUserAction { @@ -62,6 +64,7 @@ interface BuildCaseUserAction { actionAt: string; actionBy: User; caseId: string; + owner: string; fields: UserActionField | unknown[]; newValue?: string | unknown; oldValue?: string | unknown; @@ -82,11 +85,13 @@ export const buildCommentUserActionItem = ({ newValue, oldValue, subCaseId, + owner, }: BuildCommentUserActionItem): UserActionItem => ({ attributes: transformNewUserAction({ actionField: fields as UserActionField, action, actionAt, + owner, ...actionBy, newValue: newValue as string, oldValue: oldValue as string, @@ -123,11 +128,13 @@ export const buildCaseUserActionItem = ({ newValue, oldValue, subCaseId, + owner, }: BuildCaseUserAction): UserActionItem => ({ attributes: transformNewUserAction({ actionField: fields as UserActionField, action, actionAt, + owner, ...actionBy, newValue: newValue as string, oldValue: oldValue as string, @@ -159,6 +166,7 @@ const userActionFieldsAllowed: UserActionField = [ 'status', 'settings', 'sub_case', + OWNER_FIELD, ]; interface CaseSubIDs { @@ -181,7 +189,14 @@ interface Getters { getCaseAndSubID: GetCaseAndSubID; } -const buildGenericCaseUserActions = ({ +interface OwnerEntity { + owner: string; +} + +/** + * The entity associated with the user action must contain an owner field + */ +const buildGenericCaseUserActions = ({ actionDate, actionBy, originalCases, @@ -222,6 +237,7 @@ const buildGenericCaseUserActions = ({ fields: [field], newValue: updatedValue, oldValue: origValue, + owner: originalItem.attributes.owner, }), ]; } else if (Array.isArray(origValue) && Array.isArray(updatedValue)) { @@ -237,6 +253,7 @@ const buildGenericCaseUserActions = ({ subCaseId, fields: [field], newValue: compareValues.addedItems.join(', '), + owner: originalItem.attributes.owner, }), ]; } @@ -252,6 +269,7 @@ const buildGenericCaseUserActions = ({ subCaseId, fields: [field], newValue: compareValues.deletedItems.join(', '), + owner: originalItem.attributes.owner, }), ]; } @@ -271,6 +289,7 @@ const buildGenericCaseUserActions = ({ fields: [field], newValue: JSON.stringify(updatedValue), oldValue: JSON.stringify(origValue), + owner: originalItem.attributes.owner, }), ]; } diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts index a038d843a5331..e691b9305fb37 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.ts @@ -5,19 +5,14 @@ * 2.0. */ -import { - SavedObjectsFindResponse, - Logger, - SavedObjectsBulkResponse, - SavedObjectReference, -} from 'kibana/server'; +import { Logger, SavedObjectReference } from 'kibana/server'; import { CaseUserActionAttributes } from '../../../common'; import { CASE_USER_ACTION_SAVED_OBJECT, CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT, -} from '../../saved_object_types'; +} from '../../../common/constants'; import { ClientArgs } from '..'; interface GetCaseUserActionArgs extends ClientArgs { @@ -34,52 +29,44 @@ interface PostCaseUserActionArgs extends ClientArgs { actions: UserActionItem[]; } -export interface CaseUserActionServiceSetup { - getUserActions( - args: GetCaseUserActionArgs - ): Promise>; - postUserActions( - args: PostCaseUserActionArgs - ): Promise>; -} - export class CaseUserActionService { constructor(private readonly log: Logger) {} - public setup = async (): Promise => ({ - getUserActions: async ({ client, caseId, subCaseId }: GetCaseUserActionArgs) => { - try { - const id = subCaseId ?? caseId; - const type = subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; - const caseUserActionInfo = await client.find({ - type: CASE_USER_ACTION_SAVED_OBJECT, - fields: [], - hasReference: { type, id }, - page: 1, - perPage: 1, - }); - return await client.find({ - type: CASE_USER_ACTION_SAVED_OBJECT, - hasReference: { type, id }, - page: 1, - perPage: caseUserActionInfo.total, - sortField: 'action_at', - sortOrder: 'asc', - }); - } catch (error) { - this.log.error(`Error on GET case user action case id: ${caseId}: ${error}`); - throw error; - } - }, - postUserActions: async ({ client, actions }: PostCaseUserActionArgs) => { - try { - this.log.debug(`Attempting to POST a new case user action`); - return await client.bulkCreate( - actions.map((action) => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) - ); - } catch (error) { - this.log.error(`Error on POST a new case user action: ${error}`); - throw error; - } - }, - }); + + public async getAll({ unsecuredSavedObjectsClient, caseId, subCaseId }: GetCaseUserActionArgs) { + try { + const id = subCaseId ?? caseId; + const type = subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const caseUserActionInfo = await unsecuredSavedObjectsClient.find({ + type: CASE_USER_ACTION_SAVED_OBJECT, + fields: [], + hasReference: { type, id }, + page: 1, + perPage: 1, + }); + + return await unsecuredSavedObjectsClient.find({ + type: CASE_USER_ACTION_SAVED_OBJECT, + hasReference: { type, id }, + page: 1, + perPage: caseUserActionInfo.total, + sortField: 'action_at', + sortOrder: 'asc', + }); + } catch (error) { + this.log.error(`Error on GET case user action case id: ${caseId}: ${error}`); + throw error; + } + } + + public async bulkCreate({ unsecuredSavedObjectsClient, actions }: PostCaseUserActionArgs) { + try { + this.log.debug(`Attempting to POST a new case user action`); + return await unsecuredSavedObjectsClient.bulkCreate( + actions.map((action) => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) + ); + } catch (error) { + this.log.error(`Error on POST a new case user action: ${error}`); + throw error; + } + } } diff --git a/x-pack/plugins/cases/server/types.ts b/x-pack/plugins/cases/server/types.ts index 420890c6f80fe..c3b8e0a273221 100644 --- a/x-pack/plugins/cases/server/types.ts +++ b/x-pack/plugins/cases/server/types.ts @@ -6,11 +6,17 @@ */ import type { IRouter, RequestHandlerContext } from 'src/core/server'; -import type { ActionsApiRequestHandlerContext } from '../../actions/server'; +import { + ActionTypeConfig, + ActionTypeSecrets, + ActionTypeParams, + ActionType, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../actions/server/types'; import { CasesClient } from './client'; export interface CaseRequestContext { - getCasesClient: () => CasesClient; + getCasesClient: () => Promise; } /** @@ -18,10 +24,18 @@ export interface CaseRequestContext { */ export interface CasesRequestHandlerContext extends RequestHandlerContext { cases: CaseRequestContext; - actions: ActionsApiRequestHandlerContext; } /** * @internal */ export type CasesRouter = IRouter; + +export type RegisterActionType = < + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets, + Params extends ActionTypeParams = ActionTypeParams, + ExecutorResultData = void +>( + actionType: ActionType +) => void; diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index 166ce5b62a067..cb403ce673f11 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -127,6 +127,34 @@ export interface FeatureKibanaPrivileges { read?: readonly string[]; }; }; + + /** + * If your feature requires access to specific owners of cases (aka plugins that have created cases), then specify your access needs here. The values here should + * be unique identifiers for the owners of cases you want access to. + */ + cases?: { + /** + * List of case owners which users should have full read/write access to when granted this privilege. + * @example + * ```ts + * { + * all: ['securitySolution'] + * } + * ``` + */ + all?: readonly string[]; + /** + * List of case owners which users should have read-only access to when granted this privilege. + * @example + * ```ts + * { + * read: ['securitySolution'] + * } + * ``` + */ + read?: readonly string[]; + }; + /** * If your feature requires access to specific saved objects, then specify your access needs here. */ diff --git a/x-pack/plugins/features/common/kibana_feature.ts b/x-pack/plugins/features/common/kibana_feature.ts index 7c9f930c106b0..089389c7bc7fa 100644 --- a/x-pack/plugins/features/common/kibana_feature.ts +++ b/x-pack/plugins/features/common/kibana_feature.ts @@ -98,6 +98,11 @@ export interface KibanaFeatureConfig { */ alerting?: readonly string[]; + /** + * If your feature grants access to specific case types, you can specify them here to control visibility based on the current space. + */ + cases?: readonly string[]; + /** * Feature privilege definition. * @@ -183,6 +188,10 @@ export class KibanaFeature { return this.config.alerting; } + public get cases() { + return this.config.cases; + } + public get excludeFromBasePrivileges() { return this.config.excludeFromBasePrivileges ?? false; } diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index 64be725e02bbe..e1e5776d87c75 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -528,6 +528,10 @@ Array [ "dashboards", "kibana", ], + "cases": Object { + "all": Array [], + "read": Array [], + }, "catalogue": Array [ "dashboard", ], @@ -673,6 +677,10 @@ Array [ "discover", "kibana", ], + "cases": Object { + "all": Array [], + "read": Array [], + }, "catalogue": Array [ "discover", ], @@ -915,6 +923,10 @@ Array [ "lens", "kibana", ], + "cases": Object { + "all": Array [], + "read": Array [], + }, "catalogue": Array [ "visualize", ], @@ -1044,6 +1056,10 @@ Array [ "dashboards", "kibana", ], + "cases": Object { + "all": Array [], + "read": Array [], + }, "catalogue": Array [ "dashboard", ], @@ -1189,6 +1205,10 @@ Array [ "discover", "kibana", ], + "cases": Object { + "all": Array [], + "read": Array [], + }, "catalogue": Array [ "discover", ], @@ -1431,6 +1451,10 @@ Array [ "lens", "kibana", ], + "cases": Object { + "all": Array [], + "read": Array [], + }, "catalogue": Array [ "visualize", ], diff --git a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts index 75e6eaa402091..6275d6aa9dce9 100644 --- a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts +++ b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.test.ts @@ -55,6 +55,10 @@ describe('featurePrivilegeIterator', () => { read: [], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, read: { @@ -76,6 +80,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -113,6 +120,10 @@ describe('featurePrivilegeIterator', () => { read: [], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -137,6 +148,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -170,6 +184,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type-alerts'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, read: { @@ -191,6 +209,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -229,6 +250,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type-alerts'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -261,6 +286,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, read: { @@ -282,6 +311,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -311,6 +343,10 @@ describe('featurePrivilegeIterator', () => { all: ['alerting-all-sub-type'], }, }, + cases: { + all: ['cases-all-sub-type'], + read: ['cases-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -349,6 +385,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -373,6 +413,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -405,6 +448,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, read: { @@ -426,6 +473,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -455,6 +505,10 @@ describe('featurePrivilegeIterator', () => { all: ['alerting-all-sub-type'], }, }, + cases: { + all: ['cases-all-sub-type'], + read: ['cases-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -493,6 +547,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -517,6 +575,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -549,6 +610,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, read: { @@ -570,6 +635,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -600,6 +668,10 @@ describe('featurePrivilegeIterator', () => { all: ['alerting-all-sub-type'], }, }, + cases: { + all: ['cases-all-sub-type'], + read: ['cases-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -641,6 +713,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type', 'cases-all-sub-type'], + read: ['cases-read-type', 'cases-read-sub-type'], + }, ui: ['ui-action', 'ui-sub-type'], }, }, @@ -668,6 +744,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + all: ['cases-all-sub-type'], + read: ['cases-read-type', 'cases-read-sub-type'], + }, ui: ['ui-action', 'ui-sub-type'], }, }, @@ -702,6 +782,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, read: { @@ -723,6 +807,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -752,6 +839,9 @@ describe('featurePrivilegeIterator', () => { all: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, ], @@ -792,6 +882,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -818,6 +912,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + all: [], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -850,6 +948,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, read: { @@ -871,6 +973,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -901,6 +1006,10 @@ describe('featurePrivilegeIterator', () => { all: ['alerting-all-sub-type'], }, }, + cases: { + all: ['cases-all-sub-type'], + read: ['cases-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -942,6 +1051,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type', 'cases-all-sub-type'], + read: ['cases-read-type', 'cases-read-sub-type'], + }, ui: ['ui-action', 'ui-sub-type'], }, }, @@ -966,6 +1079,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -998,6 +1114,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, read: { @@ -1019,6 +1139,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -1050,6 +1173,10 @@ describe('featurePrivilegeIterator', () => { all: ['alerting-all-sub-type'], }, }, + cases: { + all: ['cases-all-sub-type'], + read: ['cases-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -1088,6 +1215,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -1112,6 +1243,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -1168,6 +1302,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-sub-type'], }, }, + cases: { + all: ['cases-all-sub-type'], + read: ['cases-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -1209,6 +1347,10 @@ describe('featurePrivilegeIterator', () => { read: [], }, }, + cases: { + all: ['cases-all-sub-type'], + read: ['cases-read-sub-type'], + }, ui: ['ui-sub-type'], }, }, @@ -1236,6 +1378,10 @@ describe('featurePrivilegeIterator', () => { read: [], }, }, + cases: { + all: ['cases-all-sub-type'], + read: ['cases-read-sub-type'], + }, ui: ['ui-sub-type'], }, }, @@ -1268,6 +1414,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, read: { @@ -1289,6 +1439,9 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -1347,6 +1500,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-another-read-type'], }, }, + cases: { + all: ['cases-all-type'], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, @@ -1373,6 +1530,10 @@ describe('featurePrivilegeIterator', () => { read: ['alerting-read-type'], }, }, + cases: { + all: [], + read: ['cases-read-type'], + }, ui: ['ui-action'], }, }, diff --git a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts index b58f72b0fadc0..8843423ed3c43 100644 --- a/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts +++ b/x-pack/plugins/features/server/feature_privilege_iterator/feature_privilege_iterator.ts @@ -131,6 +131,11 @@ function mergeWithSubFeatures( ), }, }; + + mergedConfig.cases = { + all: mergeArrays(mergedConfig.cases?.all ?? [], subFeaturePrivilege.cases?.all ?? []), + read: mergeArrays(mergedConfig.cases?.read ?? [], subFeaturePrivilege.cases?.read ?? []), + }; } return mergedConfig; } diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index 8e7ed45f33f50..984d76a17d159 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -1228,6 +1228,180 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents privileges from specifying cases entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + cases: ['bar'], + privileges: { + all: { + cases: { + all: ['foo', 'bar'], + read: ['baz'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + cases: { read: ['foo', 'bar', 'baz'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.all has unknown cases entries: foo, baz"` + ); + }); + + it(`prevents features from specifying cases entries that don't exist at the privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + cases: ['foo', 'bar', 'baz'], + privileges: { + all: { + cases: { all: ['foo'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + cases: { all: ['foo'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + cases: { all: ['bar'] }, + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies cases entries which are not granted to any privileges: baz"` + ); + }); + + it(`prevents reserved privileges from specifying cases entries that don't exist at the root level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + cases: ['bar'], + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + cases: { all: ['foo', 'bar', 'baz'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.reserved has unknown cases entries: foo, baz"` + ); + }); + + it(`prevents features from specifying cases entries that don't exist at the reserved privilege level`, () => { + const feature: KibanaFeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + category: { id: 'foo', label: 'foo' }, + cases: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + cases: { all: ['foo', 'bar'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => + featureRegistry.registerKibanaFeature(feature) + ).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies cases entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents privileges from specifying management sections that don't exist at the root level`, () => { const feature: KibanaFeatureConfig = { id: 'test-feature', diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 00272efc8aa78..96d79982afcf1 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -63,6 +63,7 @@ const managementSchema = schema.recordOf( ); const catalogueSchema = listOfCapabilitiesSchema; const alertingSchema = schema.arrayOf(schema.string()); +const casesSchema = schema.arrayOf(schema.string()); const appCategorySchema = schema.object({ id: schema.string(), @@ -94,6 +95,12 @@ const kibanaPrivilegeSchema = schema.object({ ), }) ), + cases: schema.maybe( + schema.object({ + all: schema.maybe(casesSchema), + read: schema.maybe(casesSchema), + }) + ), savedObject: schema.object({ all: schema.arrayOf(schema.string()), read: schema.arrayOf(schema.string()), @@ -130,6 +137,12 @@ const kibanaIndependentSubFeaturePrivilegeSchema = schema.object({ ), }) ), + cases: schema.maybe( + schema.object({ + all: schema.maybe(casesSchema), + read: schema.maybe(casesSchema), + }) + ), api: schema.maybe(schema.arrayOf(schema.string())), app: schema.maybe(schema.arrayOf(schema.string())), savedObject: schema.object({ @@ -187,6 +200,7 @@ const kibanaFeatureSchema = schema.object({ management: schema.maybe(managementSchema), catalogue: schema.maybe(catalogueSchema), alerting: schema.maybe(alertingSchema), + cases: schema.maybe(casesSchema), privileges: schema.oneOf([ schema.literal(null), schema.object({ @@ -252,7 +266,7 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { kibanaFeatureSchema.validate(feature); // the following validation can't be enforced by the Joi schema, since it'd require us looking "up" the object graph for the list of valid value, which they explicitly forbid. - const { app = [], management = {}, catalogue = [], alerting = [] } = feature; + const { app = [], management = {}, catalogue = [], alerting = [], cases = [] } = feature; const unseenApps = new Set(app); @@ -267,6 +281,8 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { const unseenAlertTypes = new Set(alerting); + const unseenCasesTypes = new Set(cases); + function validateAppEntry(privilegeId: string, entry: readonly string[] = []) { entry.forEach((privilegeApp) => unseenApps.delete(privilegeApp)); @@ -310,6 +326,23 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { } } + function validateCasesEntry(privilegeId: string, entry: FeatureKibanaPrivileges['cases']) { + const all = entry?.all ?? []; + const read = entry?.read ?? []; + + all.forEach((privilegeCasesTypes) => unseenCasesTypes.delete(privilegeCasesTypes)); + read.forEach((privilegeCasesTypes) => unseenCasesTypes.delete(privilegeCasesTypes)); + + const unknownCasesEntries = difference([...all, ...read], cases); + if (unknownCasesEntries.length > 0) { + throw new Error( + `Feature privilege ${ + feature.id + }.${privilegeId} has unknown cases entries: ${unknownCasesEntries.join(', ')}` + ); + } + } + function validateManagementEntry( privilegeId: string, managementEntry: Record = {} @@ -371,6 +404,7 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { validateManagementEntry(privilegeId, privilegeDefinition.management); validateAlertingEntry(privilegeId, privilegeDefinition.alerting); + validateCasesEntry(privilegeId, privilegeDefinition.cases); }); const subFeatureEntries = feature.subFeatures ?? []; @@ -381,6 +415,7 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { validateCatalogueEntry(subFeaturePrivilege.id, subFeaturePrivilege.catalogue); validateManagementEntry(subFeaturePrivilege.id, subFeaturePrivilege.management); validateAlertingEntry(subFeaturePrivilege.id, subFeaturePrivilege.alerting); + validateCasesEntry(subFeaturePrivilege.id, subFeaturePrivilege.cases); }); }); }); @@ -431,6 +466,16 @@ export function validateKibanaFeature(feature: KibanaFeatureConfig) { ).join(',')}` ); } + + if (unseenCasesTypes.size > 0) { + throw new Error( + `Feature ${ + feature.id + } specifies cases entries which are not granted to any privileges: ${Array.from( + unseenCasesTypes.values() + ).join(',')}` + ); + } } export function validateElasticsearchFeature(feature: ElasticsearchFeatureConfig) { diff --git a/x-pack/plugins/security/server/authorization/actions/__snapshots__/cases.test.ts.snap b/x-pack/plugins/security/server/authorization/actions/__snapshots__/cases.test.ts.snap new file mode 100644 index 0000000000000..33140f180ad0a --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/__snapshots__/cases.test.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#get operation of "" 1`] = `"operation is required and must be a string"`; + +exports[`#get operation of "{}" 1`] = `"operation is required and must be a string"`; + +exports[`#get operation of "1" 1`] = `"operation is required and must be a string"`; + +exports[`#get operation of "null" 1`] = `"operation is required and must be a string"`; + +exports[`#get operation of "true" 1`] = `"operation is required and must be a string"`; + +exports[`#get operation of "undefined" 1`] = `"operation is required and must be a string"`; + +exports[`#get owner of "" 1`] = `"owner is required and must be a string"`; + +exports[`#get owner of "{}" 1`] = `"owner is required and must be a string"`; + +exports[`#get owner of "1" 1`] = `"owner is required and must be a string"`; + +exports[`#get owner of "null" 1`] = `"owner is required and must be a string"`; + +exports[`#get owner of "true" 1`] = `"owner is required and must be a string"`; + +exports[`#get owner of "undefined" 1`] = `"owner is required and must be a string"`; diff --git a/x-pack/plugins/security/server/authorization/actions/actions.mock.ts b/x-pack/plugins/security/server/authorization/actions/actions.mock.ts index 97890e21c0eb7..ba627f08c00ca 100644 --- a/x-pack/plugins/security/server/authorization/actions/actions.mock.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.mock.ts @@ -9,6 +9,7 @@ import type { Actions } from './actions'; import { AlertingActions } from './alerting'; import { ApiActions } from './api'; import { AppActions } from './app'; +import { CasesActions } from './cases'; import { SavedObjectActions } from './saved_object'; import { SpaceActions } from './space'; import { UIActions } from './ui'; @@ -19,6 +20,7 @@ jest.mock('./saved_object'); jest.mock('./space'); jest.mock('./ui'); jest.mock('./alerting'); +jest.mock('./cases'); const create = (versionNumber: string) => { const t = ({ @@ -27,6 +29,7 @@ const create = (versionNumber: string) => { login: 'login:', savedObject: new SavedObjectActions(versionNumber), alerting: new AlertingActions(versionNumber), + cases: new CasesActions(versionNumber), space: new SpaceActions(versionNumber), ui: new UIActions(versionNumber), version: `version:${versionNumber}`, diff --git a/x-pack/plugins/security/server/authorization/actions/actions.ts b/x-pack/plugins/security/server/authorization/actions/actions.ts index 23d07f73f04be..d0466645213fa 100644 --- a/x-pack/plugins/security/server/authorization/actions/actions.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.ts @@ -8,6 +8,7 @@ import { AlertingActions } from './alerting'; import { ApiActions } from './api'; import { AppActions } from './app'; +import { CasesActions } from './cases'; import { SavedObjectActions } from './saved_object'; import { SpaceActions } from './space'; import { UIActions } from './ui'; @@ -21,6 +22,8 @@ export class Actions { public readonly app = new AppActions(this.versionNumber); + public readonly cases = new CasesActions(this.versionNumber); + public readonly login = 'login:'; public readonly savedObject = new SavedObjectActions(this.versionNumber); diff --git a/x-pack/plugins/security/server/authorization/actions/cases.test.ts b/x-pack/plugins/security/server/authorization/actions/cases.test.ts new file mode 100644 index 0000000000000..3981f49a4fe11 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/cases.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 { CasesActions } from './cases'; + +const version = '1.0.0-zeta1'; + +describe('#get', () => { + it.each` + operation + ${null} + ${undefined} + ${''} + ${1} + ${true} + ${{}} + `(`operation of ${JSON.stringify('$operation')}`, ({ operation }) => { + const actions = new CasesActions(version); + expect(() => actions.get('owner', operation)).toThrowErrorMatchingSnapshot(); + }); + + it.each` + owner + ${null} + ${undefined} + ${''} + ${1} + ${true} + ${{}} + `(`owner of ${JSON.stringify('$owner')}`, ({ owner }) => { + const actions = new CasesActions(version); + expect(() => actions.get(owner, 'operation')).toThrowErrorMatchingSnapshot(); + }); + + it('returns `cases:${owner}/${operation}`', () => { + const alertingActions = new CasesActions(version); + expect(alertingActions.get('security', 'bar-operation')).toBe( + 'cases:1.0.0-zeta1:security/bar-operation' + ); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/actions/cases.ts b/x-pack/plugins/security/server/authorization/actions/cases.ts new file mode 100644 index 0000000000000..63955ea9023ed --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/cases.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isString } from 'lodash'; + +export class CasesActions { + private readonly prefix: string; + + constructor(versionNumber: string) { + this.prefix = `cases:${versionNumber}:`; + } + + public get(owner: string, operation: string): string { + if (!operation || !isString(operation)) { + throw new Error('operation is required and must be a string'); + } + + if (!owner || !isString(owner)) { + throw new Error('owner is required and must be a string'); + } + + return `${this.prefix}${owner}/${operation}`; + } +} diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.test.ts new file mode 100644 index 0000000000000..b7550a0717a28 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.test.ts @@ -0,0 +1,263 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FeatureKibanaPrivileges } from '../../../../../features/server'; +import { KibanaFeature } from '../../../../../features/server'; +import { Actions } from '../../actions'; +import { FeaturePrivilegeCasesBuilder } from './cases'; + +const version = '1.0.0-zeta1'; + +describe(`cases`, () => { + describe(`feature_privilege_builder`, () => { + it('grants no privileges by default', () => { + const actions = new Actions(version); + const casesFeaturePrivileges = new FeaturePrivilegeCasesBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(casesFeaturePrivileges.getActions(privilege, feature)).toEqual([]); + }); + + describe(`within feature`, () => { + it('grants `read` privileges under feature', () => { + const actions = new Actions(version); + const casesFeaturePrivilege = new FeaturePrivilegeCasesBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + cases: { + read: ['observability'], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(casesFeaturePrivilege.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "cases:1.0.0-zeta1:observability/getCase", + "cases:1.0.0-zeta1:observability/getComment", + "cases:1.0.0-zeta1:observability/getTags", + "cases:1.0.0-zeta1:observability/getReporters", + "cases:1.0.0-zeta1:observability/getUserActions", + "cases:1.0.0-zeta1:observability/findConfigurations", + ] + `); + }); + + it('grants `all` privileges under feature', () => { + const actions = new Actions(version); + const casesFeaturePrivilege = new FeaturePrivilegeCasesBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + cases: { + all: ['security'], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(casesFeaturePrivilege.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "cases:1.0.0-zeta1:security/getCase", + "cases:1.0.0-zeta1:security/getComment", + "cases:1.0.0-zeta1:security/getTags", + "cases:1.0.0-zeta1:security/getReporters", + "cases:1.0.0-zeta1:security/getUserActions", + "cases:1.0.0-zeta1:security/findConfigurations", + "cases:1.0.0-zeta1:security/createCase", + "cases:1.0.0-zeta1:security/deleteCase", + "cases:1.0.0-zeta1:security/updateCase", + "cases:1.0.0-zeta1:security/pushCase", + "cases:1.0.0-zeta1:security/createComment", + "cases:1.0.0-zeta1:security/deleteComment", + "cases:1.0.0-zeta1:security/updateComment", + "cases:1.0.0-zeta1:security/createConfiguration", + "cases:1.0.0-zeta1:security/updateConfiguration", + ] + `); + }); + + it('grants both `all` and `read` privileges under feature', () => { + const actions = new Actions(version); + const casesFeaturePrivilege = new FeaturePrivilegeCasesBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + cases: { + all: ['security'], + read: ['obs'], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(casesFeaturePrivilege.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "cases:1.0.0-zeta1:security/getCase", + "cases:1.0.0-zeta1:security/getComment", + "cases:1.0.0-zeta1:security/getTags", + "cases:1.0.0-zeta1:security/getReporters", + "cases:1.0.0-zeta1:security/getUserActions", + "cases:1.0.0-zeta1:security/findConfigurations", + "cases:1.0.0-zeta1:security/createCase", + "cases:1.0.0-zeta1:security/deleteCase", + "cases:1.0.0-zeta1:security/updateCase", + "cases:1.0.0-zeta1:security/pushCase", + "cases:1.0.0-zeta1:security/createComment", + "cases:1.0.0-zeta1:security/deleteComment", + "cases:1.0.0-zeta1:security/updateComment", + "cases:1.0.0-zeta1:security/createConfiguration", + "cases:1.0.0-zeta1:security/updateConfiguration", + "cases:1.0.0-zeta1:obs/getCase", + "cases:1.0.0-zeta1:obs/getComment", + "cases:1.0.0-zeta1:obs/getTags", + "cases:1.0.0-zeta1:obs/getReporters", + "cases:1.0.0-zeta1:obs/getUserActions", + "cases:1.0.0-zeta1:obs/findConfigurations", + ] + `); + }); + + it('grants both `all` and `read` privileges under feature with multiple values in cases array', () => { + const actions = new Actions(version); + const casesFeaturePrivilege = new FeaturePrivilegeCasesBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + cases: { + all: ['security', 'other-security'], + read: ['obs', 'other-obs'], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new KibanaFeature({ + id: 'my-feature', + name: 'my-feature', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(casesFeaturePrivilege.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "cases:1.0.0-zeta1:security/getCase", + "cases:1.0.0-zeta1:security/getComment", + "cases:1.0.0-zeta1:security/getTags", + "cases:1.0.0-zeta1:security/getReporters", + "cases:1.0.0-zeta1:security/getUserActions", + "cases:1.0.0-zeta1:security/findConfigurations", + "cases:1.0.0-zeta1:security/createCase", + "cases:1.0.0-zeta1:security/deleteCase", + "cases:1.0.0-zeta1:security/updateCase", + "cases:1.0.0-zeta1:security/pushCase", + "cases:1.0.0-zeta1:security/createComment", + "cases:1.0.0-zeta1:security/deleteComment", + "cases:1.0.0-zeta1:security/updateComment", + "cases:1.0.0-zeta1:security/createConfiguration", + "cases:1.0.0-zeta1:security/updateConfiguration", + "cases:1.0.0-zeta1:other-security/getCase", + "cases:1.0.0-zeta1:other-security/getComment", + "cases:1.0.0-zeta1:other-security/getTags", + "cases:1.0.0-zeta1:other-security/getReporters", + "cases:1.0.0-zeta1:other-security/getUserActions", + "cases:1.0.0-zeta1:other-security/findConfigurations", + "cases:1.0.0-zeta1:other-security/createCase", + "cases:1.0.0-zeta1:other-security/deleteCase", + "cases:1.0.0-zeta1:other-security/updateCase", + "cases:1.0.0-zeta1:other-security/pushCase", + "cases:1.0.0-zeta1:other-security/createComment", + "cases:1.0.0-zeta1:other-security/deleteComment", + "cases:1.0.0-zeta1:other-security/updateComment", + "cases:1.0.0-zeta1:other-security/createConfiguration", + "cases:1.0.0-zeta1:other-security/updateConfiguration", + "cases:1.0.0-zeta1:obs/getCase", + "cases:1.0.0-zeta1:obs/getComment", + "cases:1.0.0-zeta1:obs/getTags", + "cases:1.0.0-zeta1:obs/getReporters", + "cases:1.0.0-zeta1:obs/getUserActions", + "cases:1.0.0-zeta1:obs/findConfigurations", + "cases:1.0.0-zeta1:other-obs/getCase", + "cases:1.0.0-zeta1:other-obs/getComment", + "cases:1.0.0-zeta1:other-obs/getTags", + "cases:1.0.0-zeta1:other-obs/getReporters", + "cases:1.0.0-zeta1:other-obs/getUserActions", + "cases:1.0.0-zeta1:other-obs/findConfigurations", + ] + `); + }); + }); + }); +}); 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 new file mode 100644 index 0000000000000..4b04f98704c8f --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { uniq } from 'lodash'; + +import type { FeatureKibanaPrivileges, KibanaFeature } from '../../../../../features/server'; +import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; + +// if you add a value here you'll likely also need to make changes here: +// x-pack/plugins/cases/server/authorization/index.ts +const readOperations: string[] = [ + 'getCase', + 'getComment', + 'getTags', + 'getReporters', + 'getUserActions', + 'findConfigurations', +]; +const writeOperations: string[] = [ + 'createCase', + 'deleteCase', + 'updateCase', + 'pushCase', + 'createComment', + 'deleteComment', + 'updateComment', + 'createConfiguration', + 'updateConfiguration', +]; +const allOperations: string[] = [...readOperations, ...writeOperations]; + +export class FeaturePrivilegeCasesBuilder extends BaseFeaturePrivilegeBuilder { + public getActions( + privilegeDefinition: FeatureKibanaPrivileges, + feature: KibanaFeature + ): string[] { + const getCasesPrivilege = (operations: string[], owners: readonly string[]) => { + return owners.flatMap((owner) => + operations.map((operation) => this.actions.cases.get(owner, operation)) + ); + }; + + return uniq([ + ...getCasesPrivilege(allOperations, privilegeDefinition.cases?.all ?? []), + ...getCasesPrivilege(readOperations, privilegeDefinition.cases?.read ?? []), + ]); + } +} diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts index 21cf2421ce1b2..81d1339052301 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts @@ -12,6 +12,7 @@ import type { Actions } from '../../actions'; import { FeaturePrivilegeAlertingBuilder } from './alerting'; import { FeaturePrivilegeApiBuilder } from './api'; import { FeaturePrivilegeAppBuilder } from './app'; +import { FeaturePrivilegeCasesBuilder } from './cases'; import { FeaturePrivilegeCatalogueBuilder } from './catalogue'; import { FeaturePrivilegeBuilder } from './feature_privilege_builder'; import { FeaturePrivilegeManagementBuilder } from './management'; @@ -31,6 +32,7 @@ export const featurePrivilegeBuilderFactory = (actions: Actions): FeaturePrivile new FeaturePrivilegeSavedObjectBuilder(actions), new FeaturePrivilegeUIBuilder(actions), new FeaturePrivilegeAlertingBuilder(actions), + new FeaturePrivilegeCasesBuilder(actions), ]; return { diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 0fa6c553c2e80..574e37fdd1841 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -83,6 +83,9 @@ describe('Security Plugin', () => { "app": AppActions { "prefix": "app:version:", }, + "cases": CasesActions { + "prefix": "cases:version:", + }, "login": "login:", "savedObject": SavedObjectActions { "prefix": "saved_object:version:", @@ -150,6 +153,9 @@ describe('Security Plugin', () => { "app": AppActions { "prefix": "app:version:", }, + "cases": CasesActions { + "prefix": "cases:version:", + }, "login": "login:", "savedObject": SavedObjectActions { "prefix": "saved_object:version:", diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts index 996df2a8fe60a..9e55067ce4ed4 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts @@ -40,6 +40,8 @@ describe('Cases connectors', () => { { source: 'comments', target: 'comments', action_type: 'append' }, ], version: 'WzEwNCwxXQ==', + id: '123', + owner: 'securitySolution', }; beforeEach(() => { cleanKibana(); @@ -53,16 +55,18 @@ describe('Cases connectors', () => { cy.intercept('GET', '/api/cases/configure', (req) => { req.reply((res) => { const resBody = - res.body.version != null - ? { - ...res.body, - error: null, - mappings: [ - { source: 'title', target: 'short_description', action_type: 'overwrite' }, - { source: 'description', target: 'description', action_type: 'overwrite' }, - { source: 'comments', target: 'comments', action_type: 'append' }, - ], - } + res.body.length > 0 && res.body[0].version != null + ? [ + { + ...res.body[0], + error: null, + mappings: [ + { source: 'title', target: 'short_description', action_type: 'overwrite' }, + { source: 'description', target: 'description', action_type: 'overwrite' }, + { source: 'comments', target: 'comments', action_type: 'append' }, + ], + }, + ] : res.body; res.send(200, resBody); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/privileges.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/privileges.spec.ts new file mode 100644 index 0000000000000..4d6c60e93ee20 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/cases/privileges.spec.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { TestCaseWithoutTimeline } from '../../objects/case'; +import { ALL_CASES_NAME } from '../../screens/all_cases'; + +import { goToCreateNewCase } from '../../tasks/all_cases'; +import { cleanKibana, deleteCases } from '../../tasks/common'; + +import { + backToCases, + createCase, + fillCasesMandatoryfields, + filterStatusOpen, +} from '../../tasks/create_new_case'; +import { + constructUrlWithUser, + getEnvAuth, + loginWithUserAndWaitForPageWithoutDateRange, +} from '../../tasks/login'; + +import { CASES_URL } from '../../urls/navigation'; + +interface User { + username: string; + password: string; + description?: string; + roles: string[]; +} + +interface UserInfo { + username: string; + full_name: string; + email: string; +} + +interface FeaturesPrivileges { + [featureId: string]: string[]; +} + +interface ElasticsearchIndices { + names: string[]; + privileges: string[]; +} + +interface ElasticSearchPrivilege { + cluster?: string[]; + indices?: ElasticsearchIndices[]; +} + +interface KibanaPrivilege { + spaces: string[]; + base?: string[]; + feature?: FeaturesPrivileges; +} + +interface Role { + name: string; + privileges: { + elasticsearch?: ElasticSearchPrivilege; + kibana?: KibanaPrivilege[]; + }; +} + +const secAll: Role = { + name: 'sec_all_role', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + siem: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +const secAllUser: User = { + username: 'sec_all_user', + password: 'password', + roles: [secAll.name], +}; + +const secReadCasesAll: Role = { + name: 'sec_read_cases_all_role', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + siem: ['minimal_read', 'cases_all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +const secReadCasesAllUser: User = { + username: 'sec_read_cases_all_user', + password: 'password', + roles: [secReadCasesAll.name], +}; + +const usersToCreate = [secAllUser, secReadCasesAllUser]; +const rolesToCreate = [secAll, secReadCasesAll]; + +const getUserInfo = (user: User): UserInfo => ({ + username: user.username, + full_name: user.username.replace('_', ' '), + email: `${user.username}@elastic.co`, +}); + +const createUsersAndRoles = (users: User[], roles: Role[]) => { + const envUser = getEnvAuth(); + for (const role of roles) { + cy.log(`Creating role: ${JSON.stringify(role)}`); + cy.request({ + body: role.privileges, + headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, + method: 'PUT', + url: constructUrlWithUser(envUser, `/api/security/role/${role.name}`), + }) + .its('status') + .should('eql', 204); + } + + for (const user of users) { + const userInfo = getUserInfo(user); + cy.log(`Creating user: ${JSON.stringify(user)}`); + cy.request({ + body: { + username: user.username, + password: user.password, + roles: user.roles, + full_name: userInfo.full_name, + email: userInfo.email, + }, + headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, + method: 'POST', + url: constructUrlWithUser(envUser, `/internal/security/users/${user.username}`), + }) + .its('status') + .should('eql', 200); + } +}; + +const deleteUsersAndRoles = (users: User[], roles: Role[]) => { + const envUser = getEnvAuth(); + for (const user of users) { + cy.log(`Deleting user: ${JSON.stringify(user)}`); + cy.request({ + headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, + method: 'DELETE', + url: constructUrlWithUser(envUser, `/internal/security/users/${user.username}`), + failOnStatusCode: false, + }) + .its('status') + .should('oneOf', [204, 404]); + } + + for (const role of roles) { + cy.log(`Deleting role: ${JSON.stringify(role)}`); + cy.request({ + headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, + method: 'DELETE', + url: constructUrlWithUser(envUser, `/api/security/role/${role.name}`), + failOnStatusCode: false, + }) + .its('status') + .should('oneOf', [204, 404]); + } +}; + +const testCase: TestCaseWithoutTimeline = { + name: 'This is the title of the case', + tags: ['Tag1', 'Tag2'], + description: 'This is the case description', + reporter: 'elastic', + owner: 'securitySolution', +}; + +describe('Cases privileges', () => { + before(() => { + cleanKibana(); + createUsersAndRoles(usersToCreate, rolesToCreate); + }); + + after(() => { + deleteUsersAndRoles(usersToCreate, rolesToCreate); + cleanKibana(); + }); + + beforeEach(() => { + deleteCases(); + }); + + for (const user of [secAllUser, secReadCasesAllUser]) { + it(`User ${user.username} with role(s) ${user.roles.join()} can create a case`, () => { + loginWithUserAndWaitForPageWithoutDateRange(CASES_URL, user); + goToCreateNewCase(); + fillCasesMandatoryfields(testCase); + createCase(); + backToCases(); + filterStatusOpen(); + + cy.get(ALL_CASES_NAME).should('have.text', testCase.name); + }); + } +}); diff --git a/x-pack/plugins/security_solution/cypress/objects/case.ts b/x-pack/plugins/security_solution/cypress/objects/case.ts index a0135431c6543..847236688dee7 100644 --- a/x-pack/plugins/security_solution/cypress/objects/case.ts +++ b/x-pack/plugins/security_solution/cypress/objects/case.ts @@ -7,12 +7,16 @@ import { CompleteTimeline, timeline } from './timeline'; -export interface TestCase { +export interface TestCase extends TestCaseWithoutTimeline { + timeline: CompleteTimeline; +} + +export interface TestCaseWithoutTimeline { name: string; tags: string[]; description: string; - timeline: CompleteTimeline; reporter: string; + owner: string; } export interface Connector { @@ -45,6 +49,7 @@ export const case1: TestCase = { description: 'This is the case description', timeline, reporter: 'elastic', + owner: 'securitySolution', }; export const serviceNowConnector: Connector = { diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/cases.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/cases.ts index f73b8e47066d2..798cd184d6012 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/cases.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/cases.ts @@ -24,6 +24,7 @@ export const createCase = (newCase: TestCase) => settings: { syncAlerts: true, }, + owner: newCase.owner, }, headers: { 'kbn-xsrf': 'cypress-creds' }, }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/common.ts b/x-pack/plugins/security_solution/cypress/tasks/common.ts index 468b0e22838dd..d726d5daa5cbc 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/common.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/common.ts @@ -106,19 +106,7 @@ export const cleanKibana = () => { }, }); - cy.request('POST', `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed`, { - query: { - bool: { - filter: [ - { - match: { - type: 'cases', - }, - }, - ], - }, - }, - }); + deleteCases(); cy.request('POST', `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed`, { query: { @@ -149,4 +137,21 @@ export const cleanKibana = () => { esArchiverResetKibana(); }; +export const deleteCases = () => { + const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_\*`; + cy.request('POST', `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed`, { + query: { + bool: { + filter: [ + { + match: { + type: 'cases', + }, + }, + ], + }, + }, + }); +}; + export const scrollToBottom = () => cy.scrollTo('bottom'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts index ed9174e2a74bb..6f1868d047c06 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts @@ -10,6 +10,7 @@ import { JiraConnectorOptions, ServiceNowconnectorOptions, TestCase, + TestCaseWithoutTimeline, } from '../objects/case'; import { ALL_CASES_OPEN_CASES_COUNT, ALL_CASES_OPEN_FILTER } from '../screens/all_cases'; @@ -46,7 +47,7 @@ export const filterStatusOpen = () => { cy.get(ALL_CASES_OPEN_FILTER).click(); }; -export const fillCasesMandatoryfields = (newCase: TestCase) => { +export const fillCasesMandatoryfields = (newCase: TestCaseWithoutTimeline) => { cy.get(TITLE_INPUT).type(newCase.name, { force: true }); newCase.tags.forEach((tag) => { cy.get(TAGS_INPUT).type(`${tag}{enter}`, { force: true }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index 0a0e578ffd382..be447993273fb 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -67,6 +67,32 @@ export const getUrlWithRoute = (role: ROLES, route: string) => { return theUrl; }; +interface User { + username: string; + password: string; +} + +/** + * Builds a URL with basic auth using the passed in user. + * + * @param user the user information to build the basic auth with + * @param route string route to visit + */ +export const constructUrlWithUser = (user: User, route: string) => { + const hostname = Cypress.env('hostname'); + const username = user.username; + const password = user.password; + const protocol = Cypress.env('protocol'); + const port = Cypress.env('configport'); + + const path = `${route.startsWith('/') ? '' : '/'}${route}`; + const strUrl = `${protocol}://${username}:${password}@${hostname}:${port}${path}`; + const builtUrl = new URL(strUrl); + + cy.log(`origin: ${builtUrl.href}`); + return builtUrl.href; +}; + export const getCurlScriptEnvVars = () => ({ ELASTICSEARCH_URL: Cypress.env('ELASTICSEARCH_URL'), ELASTICSEARCH_USERNAME: Cypress.env('ELASTICSEARCH_USERNAME'), @@ -102,6 +128,23 @@ export const deleteRoleAndUser = (role: ROLES) => { }); }; +export const loginWithUser = (user: User) => { + cy.request({ + body: { + providerType: 'basic', + providerName: 'basic', + currentURL: '/', + params: { + username: user.username, + password: user.password, + }, + }, + headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, + method: 'POST', + url: constructUrlWithUser(user, LOGIN_API_ENDPOINT), + }); +}; + export const loginWithRole = async (role: ROLES) => { postRoleAndUser(role); const theUrl = Url.format({ @@ -214,6 +257,28 @@ const loginViaConfig = () => { }); }; +/** + * Get the configured auth details that were used to spawn cypress + * + * @returns the default Elasticsearch username and password for this environment + */ +export const getEnvAuth = (): User => { + if (credentialsProvidedByEnvironment()) { + return { + username: Cypress.env(ELASTICSEARCH_USERNAME), + password: Cypress.env(ELASTICSEARCH_PASSWORD), + }; + } else { + let user: User = { username: '', password: '' }; + cy.readFile(KIBANA_DEV_YML_PATH).then((devYml) => { + const config = yaml.safeLoad(devYml); + user = { username: config.elasticsearch.username, password: config.elasticsearch.password }; + }); + + return user; + } +}; + /** * Authenticates with Kibana, visits the specified `url`, and waits for the * Kibana global nav to be displayed before continuing @@ -232,6 +297,12 @@ export const loginAndWaitForPageWithoutDateRange = (url: string, role?: ROLES) = cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 }); }; +export const loginWithUserAndWaitForPageWithoutDateRange = (url: string, user: User) => { + loginWithUser(user); + cy.visit(constructUrlWithUser(user, url)); + cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 }); +}; + export const loginAndWaitForTimeline = (timelineId: string, role?: ROLES) => { const route = `/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t)`; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index 60fa0e4aafd8e..337c07fa93eab 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -73,6 +73,7 @@ export const AllCases = React.memo(({ userCanCrud }) => { onClick: goToCreateCase, }, userCanCrud, + owner: [APP_ID], }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx index 2a7804579a57e..3409c5eb94245 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx @@ -13,8 +13,8 @@ import { ErrorMessage } from './types'; export const savedObjectReadOnlyErrorMessage: ErrorMessage = { id: 'read-only-privileges-error', - title: i18n.READ_ONLY_SAVED_OBJECT_TITLE, - description: <>{i18n.READ_ONLY_SAVED_OBJECT_MSG}, + title: i18n.READ_ONLY_FEATURE_TITLE, + description: <>{i18n.READ_ONLY_FEATURE_MSG}, errorType: 'warning', }; diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts b/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts index 4a5f32684ccde..db4809126452f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts @@ -7,15 +7,15 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_SAVED_OBJECT_TITLE = i18n.translate( - 'xpack.securitySolution.cases.readOnlySavedObjectTitle', +export const READ_ONLY_FEATURE_TITLE = i18n.translate( + 'xpack.securitySolution.cases.readOnlyFeatureTitle', { defaultMessage: 'You cannot open new or update existing cases', } ); -export const READ_ONLY_SAVED_OBJECT_MSG = i18n.translate( - 'xpack.securitySolution.cases.readOnlySavedObjectDescription', +export const READ_ONLY_FEATURE_MSG = i18n.translate( + 'xpack.securitySolution.cases.readOnlyFeatureDescription', { defaultMessage: 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', diff --git a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx index 0f9f64b32bdd0..1023bfc8b0206 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx @@ -12,6 +12,7 @@ import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eu import * as i18n from '../../translations'; import { useKibana } from '../../../common/lib/kibana'; import { Case } from '../../../../../cases/common'; +import { APP_ID } from '../../../../common/constants'; export interface CreateCaseModalProps { afterCaseCreated?: (theCase: Case) => Promise; @@ -65,6 +66,7 @@ const CreateCaseFlyoutComponent: React.FC = ({ onCancel: onCloseFlyout, onSuccess, withSteps: false, + owner: [APP_ID], })} diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx index 2d5faef8aa009..1a6015d1bbd45 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx @@ -17,6 +17,7 @@ import { Create } from '.'; import { useKibana } from '../../../common/lib/kibana'; import { Case } from '../../../../../cases/public/containers/types'; import { basicCase } from '../../../../../cases/public/containers/mock'; +import { APP_ID } from '../../../../common/constants'; jest.mock('../use_insert_timeline'); jest.mock('../../../common/lib/kibana'); @@ -47,6 +48,7 @@ describe('Create case', () => { ); expect(mockCreateCase).toHaveBeenCalled(); + expect(mockCreateCase.mock.calls[0][0].owner).toEqual([APP_ID]); }); it('should redirect to all cases on cancel click', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 4a1a64f5fcb41..f946cefd3494c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -13,6 +13,7 @@ import { getCaseDetailsUrl } from '../../../common/components/link_to'; import { useKibana } from '../../../common/lib/kibana'; import * as timelineMarkdownPlugin from '../../../common/components/markdown_editor/plugins/timeline'; import { useInsertTimeline } from '../use_insert_timeline'; +import { APP_ID } from '../../../../common/constants'; export const Create = React.memo(() => { const { cases } = useKibana().services; @@ -43,6 +44,7 @@ export const Create = React.memo(() => { useInsertTimeline, }, }, + owner: [APP_ID], })} ); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index 09c94b643e8d9..77fa9e8b3cc8c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -9,12 +9,12 @@ import React from 'react'; import { mount } from 'enzyme'; import { EuiGlobalToastList } from '@elastic/eui'; -import { useKibana, useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; +import { useKibana, useGetUserCasesPermissions } from '../../../common/lib/kibana'; import { useStateToaster } from '../../../common/components/toasters'; import { TestProviders } from '../../../common/mock'; import { AddToCaseAction } from './add_to_case_action'; import { basicCase } from '../../../../../cases/public/containers/mock'; -import { Case } from '../../../../../cases/common'; +import { Case, SECURITY_SOLUTION_OWNER } from '../../../../../cases/common'; jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/components/link_to', () => { @@ -62,7 +62,7 @@ describe('AddToCaseAction', () => { getAllCasesSelectorModal: mockAllCasesModal.mockImplementation(() => <>{'test'}), }; (useStateToaster as jest.Mock).mockReturnValue([jest.fn(), mockDispatchToaster]); - (useGetUserSavedObjectPermissions as jest.Mock).mockReturnValue({ + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ crud: true, read: true, }); @@ -116,6 +116,7 @@ describe('AddToCaseAction', () => { alertId: 'test-id', index: 'test-index', rule: { id: 'rule-id', name: 'rule-name' }, + owner: SECURITY_SOLUTION_OWNER, }); }); @@ -138,7 +139,11 @@ describe('AddToCaseAction', () => { expect(mockAllCasesModal.mock.calls[0][0].alertData).toEqual({ alertId: 'test-id', index: 'test-index', - rule: { id: 'rule-id', name: null }, + rule: { + id: 'rule-id', + name: null, + }, + owner: SECURITY_SOLUTION_OWNER, }); }); @@ -196,7 +201,7 @@ describe('AddToCaseAction', () => { }); it('disabled when user does not have crud permissions', () => { - (useGetUserSavedObjectPermissions as jest.Mock).mockReturnValue({ + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ crud: false, read: true, }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index 7379f5d6fd5dc..4435b32e07cc2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -27,7 +27,7 @@ import { } from '../../../common/components/link_to'; import { useStateToaster } from '../../../common/components/toasters'; import { useControl } from '../../../common/hooks/use_control'; -import { useGetUserSavedObjectPermissions, useKibana } from '../../../common/lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana'; import { ActionIconItem } from '../../../timelines/components/timeline/body/actions/action_icon_item'; import { CreateCaseFlyout } from '../create/flyout'; import { createUpdateSuccessToaster } from './helpers'; @@ -45,6 +45,7 @@ interface PostCommentArg { alertId: string | string[]; index: string | string[]; rule: { id: string | null; name: string | null }; + owner: string; }; updateCase?: (newCase: Case) => void; subCaseId?: string; @@ -66,7 +67,7 @@ const AddToCaseActionComponent: React.FC = ({ const [isPopoverOpen, setIsPopoverOpen] = useState(false); const openPopover = useCallback(() => setIsPopoverOpen(true), []); const closePopover = useCallback(() => setIsPopoverOpen(false), []); - const userPermissions = useGetUserSavedObjectPermissions(); + const userPermissions = useGetUserCasesPermissions(); const isEventSupported = !isEmpty(ecsRowData.signal?.rule?.id); const userCanCrud = userPermissions?.crud ?? false; @@ -110,6 +111,7 @@ const AddToCaseActionComponent: React.FC = ({ id: rule?.id != null ? rule.id[0] : null, name: rule?.name != null ? rule.name[0] : null, }, + owner: APP_ID, }, updateCase, }); @@ -235,6 +237,7 @@ const AddToCaseActionComponent: React.FC = ({ id: rule?.id != null ? rule.id[0] : null, name: rule?.name != null ? rule.name[0] : null, }, + owner: APP_ID, }, createCaseNavigation: { href: formatUrl(getCreateCaseUrl()), @@ -244,6 +247,7 @@ const AddToCaseActionComponent: React.FC = ({ onRowClick: onCaseClicked, updateCase: onCaseSuccess, userCanCrud: userPermissions?.crud ?? false, + owner: [APP_ID], })} ); diff --git a/x-pack/plugins/security_solution/public/cases/pages/case.tsx b/x-pack/plugins/security_solution/public/cases/pages/case.tsx index ff7589e9deb2a..4ec29b676afe6 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/case.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/case.tsx @@ -8,16 +8,16 @@ import React from 'react'; import { WrapperPage } from '../../common/components/wrapper_page'; -import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana'; +import { useGetUserCasesPermissions } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { AllCases } from '../components/all_cases'; import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/callout'; -import { CaseSavedObjectNoPermissions } from './saved_object_no_permissions'; +import { CaseFeatureNoPermissions } from './feature_no_permissions'; import { SecurityPageName } from '../../app/types'; export const CasesPage = React.memo(() => { - const userPermissions = useGetUserSavedObjectPermissions(); + const userPermissions = useGetUserCasesPermissions(); return userPermissions == null || userPermissions?.read ? ( <> @@ -33,7 +33,7 @@ export const CasesPage = React.memo(() => { ) : ( - + ); }); diff --git a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx index 1841ca39ae853..03407c7a5adaa 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx @@ -12,7 +12,7 @@ import { SecurityPageName } from '../../app/types'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { WrapperPage } from '../../common/components/wrapper_page'; import { useGetUrlSearch } from '../../common/components/navigation/use_get_url_search'; -import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana'; +import { useGetUserCasesPermissions } from '../../common/lib/kibana'; import { getCaseUrl } from '../../common/components/link_to'; import { navTabs } from '../../app/home/home_navigations'; import { CaseView } from '../components/case_view'; @@ -20,7 +20,7 @@ import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/call export const CaseDetailsPage = React.memo(() => { const history = useHistory(); - const userPermissions = useGetUserSavedObjectPermissions(); + const userPermissions = useGetUserCasesPermissions(); const { detailName: caseId, subCaseId } = useParams<{ detailName?: string; subCaseId?: string; diff --git a/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx b/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx index 3e838f47e6dc2..905167c232c7d 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/configure_cases.tsx @@ -13,17 +13,18 @@ import { SecurityPageName } from '../../app/types'; import { getCaseUrl } from '../../common/components/link_to'; import { useGetUrlSearch } from '../../common/components/navigation/use_get_url_search'; import { WrapperPage } from '../../common/components/wrapper_page'; -import { useGetUserSavedObjectPermissions, useKibana } from '../../common/lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { navTabs } from '../../app/home/home_navigations'; import { CaseHeaderPage } from '../components/case_header_page'; import { WhitePageWrapper, SectionWrapper } from '../components/wrappers'; import * as i18n from './translations'; +import { APP_ID } from '../../../common/constants'; const ConfigureCasesPageComponent: React.FC = () => { const { cases } = useKibana().services; const history = useHistory(); - const userPermissions = useGetUserSavedObjectPermissions(); + const userPermissions = useGetUserCasesPermissions(); const search = useGetUrlSearch(navTabs.case); const backOptions = useMemo( @@ -55,6 +56,7 @@ const ConfigureCasesPageComponent: React.FC = () => { {cases.getConfigureCases({ userCanCrud: userPermissions?.crud ?? false, + owner: [APP_ID], })} diff --git a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx index 24b179f4a41bf..41344a8deb3b1 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx @@ -12,7 +12,7 @@ import { SecurityPageName } from '../../app/types'; import { getCaseUrl } from '../../common/components/link_to'; import { useGetUrlSearch } from '../../common/components/navigation/use_get_url_search'; import { WrapperPage } from '../../common/components/wrapper_page'; -import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana'; +import { useGetUserCasesPermissions } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { navTabs } from '../../app/home/home_navigations'; import { CaseHeaderPage } from '../components/case_header_page'; @@ -21,7 +21,7 @@ import * as i18n from './translations'; export const CreateCasePage = React.memo(() => { const history = useHistory(); - const userPermissions = useGetUserSavedObjectPermissions(); + const userPermissions = useGetUserCasesPermissions(); const search = useGetUrlSearch(navTabs.case); const backOptions = useMemo( diff --git a/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx b/x-pack/plugins/security_solution/public/cases/pages/feature_no_permissions.tsx similarity index 64% rename from x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx rename to x-pack/plugins/security_solution/public/cases/pages/feature_no_permissions.tsx index dd173e18ca63e..9975db3d8b6fb 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/feature_no_permissions.tsx @@ -11,14 +11,14 @@ import { EmptyPage } from '../../common/components/empty_page'; import * as i18n from './translations'; import { useKibana } from '../../common/lib/kibana'; -export const CaseSavedObjectNoPermissions = React.memo(() => { +export const CaseFeatureNoPermissions = React.memo(() => { const docLinks = useKibana().services.docLinks; const actions = useMemo( () => ({ - savedObject: { + feature: { icon: 'documents', label: i18n.GO_TO_DOCUMENTATION, - url: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/security/${docLinks.DOC_LINK_VERSION}s`, + url: `${docLinks.links.siem.gettingStarted}`, target: '_blank', }, }), @@ -28,11 +28,11 @@ export const CaseSavedObjectNoPermissions = React.memo(() => { return ( ); }); -CaseSavedObjectNoPermissions.displayName = 'CaseSavedObjectNoPermissions'; +CaseFeatureNoPermissions.displayName = 'CaseFeatureNoPermissions'; diff --git a/x-pack/plugins/security_solution/public/cases/pages/translations.ts b/x-pack/plugins/security_solution/public/cases/pages/translations.ts index 0abf7461681cf..e45aca87ff1f9 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/pages/translations.ts @@ -7,18 +7,18 @@ import { i18n } from '@kbn/i18n'; -export const SAVED_OBJECT_NO_PERMISSIONS_TITLE = i18n.translate( - 'xpack.securitySolution.cases.caseSavedObjectNoPermissionsTitle', +export const CASES_FEATURE_NO_PERMISSIONS_TITLE = i18n.translate( + 'xpack.securitySolution.cases.caseFeatureNoPermissionsTitle', { defaultMessage: 'Kibana feature privileges required', } ); -export const SAVED_OBJECT_NO_PERMISSIONS_MSG = i18n.translate( - 'xpack.securitySolution.cases.caseSavedObjectNoPermissionsMessage', +export const CASES_FEATURE_NO_PERMISSIONS_MSG = i18n.translate( + 'xpack.securitySolution.cases.caseFeatureNoPermissionsMessage', { defaultMessage: - 'To view cases, you must have privileges for the Saved Object Management feature in the Kibana space. For more information, contact your Kibana administrator.', + 'To view cases, you must have privileges for the Cases feature in the Kibana space. For more information, contact your Kibana administrator.', } ); diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts index e8e4f207f2d23..63fc5695ebab1 100644 --- a/x-pack/plugins/security_solution/public/cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/translations.ts @@ -7,18 +7,18 @@ import { i18n } from '@kbn/i18n'; -export const SAVED_OBJECT_NO_PERMISSIONS_TITLE = i18n.translate( - 'xpack.securitySolution.cases.caseSavedObjectNoPermissionsTitle', +export const CASES_FEATURE_NO_PERMISSIONS_TITLE = i18n.translate( + 'xpack.securitySolution.cases.caseFeatureNoPermissionsTitle', { defaultMessage: 'Kibana feature privileges required', } ); -export const SAVED_OBJECT_NO_PERMISSIONS_MSG = i18n.translate( - 'xpack.securitySolution.cases.caseSavedObjectNoPermissionsMessage', +export const CASES_FEATURE_NO_PERMISSIONS_MSG = i18n.translate( + 'xpack.securitySolution.cases.caseFeatureNoPermissionsMessage', { defaultMessage: - 'To view cases, you must have privileges for the Saved Object Management feature in the Kibana space. For more information, contact your Kibana administrator.', + 'To view cases, you must have privileges for the Cases feature in the Kibana space. For more information, contact your Kibana administrator.', } ); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts index 79c7b21158005..eb0ae1ae1dee9 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts @@ -45,4 +45,4 @@ export const useToasts = jest export const useCurrentUser = jest.fn(); export const withKibana = jest.fn(createWithKibanaMock()); export const KibanaContextProvider = jest.fn(createKibanaContextProviderMock()); -export const useGetUserSavedObjectPermissions = jest.fn(); +export const useGetUserCasesPermissions = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index 6b5599292f6d4..4a2caefba1b97 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -138,28 +138,25 @@ export const useCurrentUser = (): AuthenticatedElasticUser | null => { return user; }; -export interface UseGetUserSavedObjectPermissions { +export interface UseGetUserCasesPermissions { crud: boolean; read: boolean; } -export const useGetUserSavedObjectPermissions = () => { - const [ - savedObjectsPermissions, - setSavedObjectsPermissions, - ] = useState(null); +export const useGetUserCasesPermissions = () => { + const [casesPermissions, setCasesPermissions] = useState(null); const uiCapabilities = useKibana().services.application.capabilities; useEffect(() => { const capabilitiesCanUserCRUD: boolean = - typeof uiCapabilities.siem.crud === 'boolean' ? uiCapabilities.siem.crud : false; + typeof uiCapabilities.siem.crud_cases === 'boolean' ? uiCapabilities.siem.crud_cases : false; const capabilitiesCanUserRead: boolean = - typeof uiCapabilities.siem.show === 'boolean' ? uiCapabilities.siem.show : false; - setSavedObjectsPermissions({ + typeof uiCapabilities.siem.read_cases === 'boolean' ? uiCapabilities.siem.read_cases : false; + setCasesPermissions({ crud: capabilitiesCanUserCRUD, read: capabilitiesCanUserRead, }); }, [uiCapabilities]); - return savedObjectsPermissions; + return casesPermissions; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index cd596ef76ce0a..4edbd5ab7e180 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -163,11 +163,14 @@ export const createHostUnIsolation = async ({ */ export const getCaseIdsFromAlertId = async ({ alertId, + owner, }: { alertId: string; + owner: string[]; }): Promise => KibanaServices.get().http.fetch(getCasesFromAlertsUrl(alertId), { method: 'get', + query: { ...(owner.length > 0 ? { owner } : {}) }, }); /** diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx index fb130eb744700..85b80a588e88d 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_cases_from_alerts.tsx @@ -7,6 +7,7 @@ import { isEmpty } from 'lodash'; import { useEffect, useState } from 'react'; +import { APP_ID } from '../../../../../common/constants'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { getCaseIdsFromAlertId } from './api'; import { CASES_FROM_ALERTS_FAILURE } from './translations'; @@ -28,7 +29,7 @@ export const useCasesFromAlerts = ({ alertId }: { alertId: string }): CasesFromA setLoading(true); const fetchData = async () => { try { - const casesResponse = await getCaseIdsFromAlertId({ alertId }); + const casesResponse = await getCaseIdsFromAlertId({ alertId, owner: [APP_ID] }); if (isMounted) { setCases(casesResponse); } diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx index bcf9953d70d83..fc2e2e87ffc5f 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx @@ -58,6 +58,7 @@ const RecentCasesComponent = () => { }, }, maxCasesToShow: MAX_CASES_TO_SHOW, + owner: [APP_ID], }); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx index a4c6fe1e344b3..dd21b33afa5b4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx @@ -15,7 +15,7 @@ import { APP_ID } from '../../../../../common/constants'; import { timelineSelectors } from '../../../../timelines/store/timeline'; import { setInsertTimeline, showTimeline } from '../../../store/timeline/actions'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { useGetUserSavedObjectPermissions, useKibana } from '../../../../common/lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana'; import { TimelineStatus, TimelineId, TimelineType } from '../../../../../common/types/timeline'; import { getCreateCaseUrl, @@ -71,7 +71,7 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { ); const { formatUrl } = useFormatUrl(SecurityPageName.case); - const userPermissions = useGetUserSavedObjectPermissions(); + const userPermissions = useGetUserCasesPermissions(); const goToCreateCase = useCallback( (ev) => { ev.preventDefault(); @@ -177,6 +177,7 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { }, onRowClick, userCanCrud: userPermissions?.crud ?? false, + owner: [APP_ID], })} ); diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index aebed0723c3b5..88ac1cb1d16b3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -41,6 +41,10 @@ import { ExperimentalFeatures, parseExperimentalConfigValue, } from '../../common/experimental_features'; +import { + CasesClient, + PluginStartContract as CasesPluginStartContract, +} from '../../../cases/server'; export interface MetadataService { queryStrategy( @@ -98,6 +102,7 @@ export type EndpointAppContextServiceStartContract = Partial< savedObjectsStart: SavedObjectsServiceStart; licenseService: LicenseService; exceptionListsClient: ExceptionListClient | undefined; + cases: CasesPluginStartContract | undefined; }; /** @@ -114,6 +119,7 @@ export class EndpointAppContextService { private config: ConfigType | undefined; private license: LicenseService | undefined; public security: SecurityPluginStart | undefined; + private cases: CasesPluginStartContract | undefined; private experimentalFeatures: ExperimentalFeatures | undefined; @@ -127,6 +133,7 @@ export class EndpointAppContextService { this.config = dependencies.config; this.license = dependencies.licenseService; this.security = dependencies.security; + this.cases = dependencies.cases; this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental); @@ -191,4 +198,11 @@ export class EndpointAppContextService { } return this.license; } + + public async getCasesClient(req: KibanaRequest): Promise { + if (!this.cases) { + throw new Error(`must call start on ${EndpointAppContextService.name} to call getter`); + } + return this.cases.getCasesClientWithRequest(req); + } } diff --git a/x-pack/plugins/security_solution/server/endpoint/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/mocks.ts index cdf057b8f2bb6..b2439efc63567 100644 --- a/x-pack/plugins/security_solution/server/endpoint/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/mocks.ts @@ -87,6 +87,9 @@ export const createMockEndpointAppContextServiceStartContract = (): jest.Mocked< >(), exceptionListsClient: listMock.getExceptionListClient(), packagePolicyService: createPackagePolicyServiceMock(), + cases: { + getCasesClientWithRequest: jest.fn(), + }, }; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index a8ae0eb2effae..c3ff143c2f4b4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -19,6 +19,8 @@ import { } from '../../../types'; import { getAgentIDsForEndpoints } from '../../services'; import { EndpointAppContext } from '../../types'; +import { APP_ID } from '../../../../common/constants'; +import { CommentType } from '../../../../../cases/common'; import { userCanIsolate } from '../../../../common/endpoint/actions'; /** @@ -97,6 +99,21 @@ export const isolationRequestHandler = function ( } agentIDs = [...new Set(agentIDs)]; // dedupe + // convert any alert IDs into cases + let caseIDs: string[] = req.body.case_ids?.slice() || []; + if (req.body.alert_ids && req.body.alert_ids.length > 0) { + const newIDs: string[][] = await Promise.all( + req.body.alert_ids.map(async (a: string) => + (await endpointContext.service.getCasesClient(req)).cases.getCaseIDsByAlertID({ + alertID: a, + options: { owner: APP_ID }, + }) + ) + ); + caseIDs = caseIDs.concat(...newIDs); + } + caseIDs = [...new Set(caseIDs)]; + // create an Action ID and dispatch it to ES & Fleet Server const esClient = context.core.elasticsearch.client.asCurrentUser; const actionID = uuid.v4(); @@ -133,6 +150,30 @@ export const isolationRequestHandler = function ( }, }); } + + const commentLines: string[] = []; + + commentLines.push(`${isolate ? 'I' : 'Uni'}solate action was sent to the following Agents:`); + // lines of markdown links, inside a code block + + commentLines.push( + `${agentIDs.map((a) => `- [${a}](/app/fleet#/fleet/agents/${a})`).join('\n')}` + ); + if (req.body.comment) { + commentLines.push(`\n\nWith Comment:\n> ${req.body.comment}`); + } + + caseIDs.forEach(async (caseId) => { + (await endpointContext.service.getCasesClient(req)).attachments.add({ + caseId, + comment: { + comment: commentLines.join('\n'), + type: CommentType.user, + owner: APP_ID, + }, + }); + }); + return res.ok({ body: { action: actionID, diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 4e836ea57a110..609ee88c319f9 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -29,6 +29,7 @@ import { PluginStartContract as AlertPluginStartContract, } from '../../alerting/server'; +import { PluginStartContract as CasesPluginStartContract } from '../../cases/server'; import { ECS_COMPONENT_TEMPLATE_NAME, TECHNICAL_COMPONENT_TEMPLATE_NAME, @@ -119,6 +120,7 @@ export interface StartPlugins { taskManager?: TaskManagerStartContract; telemetry?: TelemetryPluginStart; security: SecurityPluginStart; + cases?: CasesPluginStartContract; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -138,14 +140,6 @@ const securitySubPlugins = [ `${APP_ID}:${SecurityPageName.administration}`, ]; -const caseSavedObjects = [ - 'cases', - 'cases-comments', - 'cases-sub-case', - 'cases-configure', - 'cases-user-actions', -]; - export class Plugin implements IPlugin { private readonly logger: Logger; private readonly config: ConfigType; @@ -313,20 +307,57 @@ export class Plugin implements IPlugin + ui: ['crud_cases', 'read_cases'], // uiCapabilities.siem.crud_cases + cases: { + all: [APP_ID], + }, + }, + { + id: 'cases_read', + includeIn: 'read', + name: 'Read', + savedObject: { + all: [], + read: [], + }, + // using variables with underscores here otherwise when we retrieve them from the kibana + // capabilities in a hook I get type errors regarding boolean | ReadOnly<{[x: string]: boolean}> + ui: ['read_cases'], // uiCapabilities.siem.read_cases + cases: { + read: [APP_ID], + }, + }, + ], + }, + ], + }, + ], privileges: { all: { app: [...securitySubPlugins, 'kibana'], catalogue: ['securitySolution'], api: ['securitySolution', 'lists-all', 'lists-read'], savedObject: { - all: [ - 'alert', - ...caseSavedObjects, - 'exception-list', - 'exception-list-agnostic', - ...savedObjectTypes, - ], - read: ['config'], + all: ['alert', 'exception-list', 'exception-list-agnostic', ...savedObjectTypes], + read: [], }, alerting: { rule: { @@ -347,13 +378,7 @@ export class Plugin implements IPlugin { + describe('security solution cases sub feature privilege', () => { + const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertest = getService('supertest'); + + before(async () => { + await createUsersAndRoles(getService, users, roles); + }); + + after(async () => { + await deleteUsersAndRoles(getService, users, roles); + }); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + for (const user of [secAllUser, secReadCasesAllUser]) { + it(`User ${user.username} with role(s) ${user.roles.join()} can create a case`, async () => { + await createCase(supertestWithoutAuth, getPostCaseRequest({ owner: APP_ID }), 200, { + user, + space: null, + }); + }); + } + + for (const user of [ + secAllCasesReadUser, + secReadCasesAllUser, + secReadCasesReadUser, + secReadUser, + ]) { + it(`User ${user.username} with role(s) ${user.roles.join()} can get a case`, async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest({ owner: APP_ID })); + const retrievedCase = await getCase({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + expectedHttpCode: 200, + auth: { user, space: null }, + }); + + expect(caseInfo.owner).to.eql(retrievedCase.owner); + }); + } + + for (const user of [ + secAllCasesReadUser, + secAllCasesNoneUser, + secReadCasesReadUser, + secReadUser, + secReadCasesNoneUser, + ]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} cannot create a case`, async () => { + await createCase(supertestWithoutAuth, getPostCaseRequest({ owner: APP_ID }), 403, { + user, + space: null, + }); + }); + } + + for (const user of [secAllCasesNoneUser, secReadCasesNoneUser]) { + it(`User ${user.username} with role(s) ${user.roles.join()} cannot get a case`, async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest({ owner: APP_ID })); + + await getCase({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + expectedHttpCode: 403, + auth: { user, space: null }, + }); + }); + } + }); +}; diff --git a/x-pack/test/api_integration/apis/security_solution/index.js b/x-pack/test/api_integration/apis/security_solution/index.js index 3f9afba18b9ef..996f2e74e87f7 100644 --- a/x-pack/test/api_integration/apis/security_solution/index.js +++ b/x-pack/test/api_integration/apis/security_solution/index.js @@ -8,6 +8,7 @@ export default function ({ loadTestFile }) { describe('SecuritySolution Endpoints', () => { loadTestFile(require.resolve('./authentications')); + loadTestFile(require.resolve('./cases_privileges')); loadTestFile(require.resolve('./events')); loadTestFile(require.resolve('./hosts')); loadTestFile(require.resolve('./host_details')); diff --git a/x-pack/test/api_integration_basic/apis/index.ts b/x-pack/test/api_integration_basic/apis/index.ts index 323a8e95c4b2b..27869095bd792 100644 --- a/x-pack/test/api_integration_basic/apis/index.ts +++ b/x-pack/test/api_integration_basic/apis/index.ts @@ -13,5 +13,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./ml')); loadTestFile(require.resolve('./transform')); + loadTestFile(require.resolve('./security_solution')); }); } diff --git a/x-pack/test/api_integration_basic/apis/security_solution/cases_privileges.ts b/x-pack/test/api_integration_basic/apis/security_solution/cases_privileges.ts new file mode 100644 index 0000000000000..532249a049b47 --- /dev/null +++ b/x-pack/test/api_integration_basic/apis/security_solution/cases_privileges.ts @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../ftr_provider_context'; +import { + createUsersAndRoles, + deleteUsersAndRoles, +} from '../../../case_api_integration/common/lib/authentication'; + +import { Role, User } from '../../../case_api_integration/common/lib/authentication/types'; +import { + createCase, + deleteAllCaseItems, + getCase, +} from '../../../case_api_integration/common/lib/utils'; +import { getPostCaseRequest } from '../../../case_api_integration/common/lib/mock'; +import { APP_ID } from '../../../../plugins/security_solution/common/constants'; + +const secAll: Role = { + name: 'sec_all_role', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + siem: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +const secAllUser: User = { + username: 'sec_all_user', + password: 'password', + roles: [secAll.name], +}; + +const secRead: Role = { + name: 'sec_read_role', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + siem: ['read'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +const secReadUser: User = { + username: 'sec_read_user', + password: 'password', + roles: [secRead.name], +}; + +const secNone: Role = { + name: 'sec_none_role', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +const secNoneUser: User = { + username: 'sec_none_user', + password: 'password', + roles: [secNone.name], +}; + +const roles = [secAll, secRead, secNone]; + +const users = [secAllUser, secReadUser, secNoneUser]; + +export default ({ getService }: FtrProviderContext): void => { + describe('cases feature privilege', () => { + const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertest = getService('supertest'); + + before(async () => { + await createUsersAndRoles(getService, users, roles); + }); + + after(async () => { + await deleteUsersAndRoles(getService, users, roles); + }); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it(`User ${ + secAllUser.username + } with role(s) ${secAllUser.roles.join()} can create a case`, async () => { + await createCase(supertestWithoutAuth, getPostCaseRequest({ owner: APP_ID }), 200, { + user: secAllUser, + space: null, + }); + }); + + it(`User ${ + secReadUser.username + } with role(s) ${secReadUser.roles.join()} can get a case`, async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest({ owner: APP_ID })); + const retrievedCase = await getCase({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + expectedHttpCode: 200, + auth: { user: secReadUser, space: null }, + }); + + expect(caseInfo.owner).to.eql(retrievedCase.owner); + }); + + for (const user of [secReadUser, secNoneUser]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} cannot create a case`, async () => { + await createCase(supertestWithoutAuth, getPostCaseRequest({ owner: APP_ID }), 403, { + user, + space: null, + }); + }); + } + + it(`User ${ + secNoneUser.username + } with role(s) ${secNoneUser.roles.join()} cannot get a case`, async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest({ owner: APP_ID })); + + await getCase({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + expectedHttpCode: 403, + auth: { user: secNoneUser, space: null }, + }); + }); + }); +}; diff --git a/x-pack/test/api_integration_basic/apis/security_solution/index.ts b/x-pack/test/api_integration_basic/apis/security_solution/index.ts new file mode 100644 index 0000000000000..90560c6c677d4 --- /dev/null +++ b/x-pack/test/api_integration_basic/apis/security_solution/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('SecuritySolution Endpoints basic licsense', () => { + loadTestFile(require.resolve('./cases_privileges')); + }); +} diff --git a/x-pack/test/case_api_integration/basic/tests/cases/alerts/get_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/alerts/get_cases.ts deleted file mode 100644 index 140fb80949a24..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/alerts/get_cases.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; -import { postCaseReq, postCommentAlertReq } from '../../../../common/lib/mock'; -import { deleteAllCaseItems } from '../../../../common/lib/utils'; -import { CaseResponse } from '../../../../../../plugins/cases/common'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('get_cases using alertID', () => { - const createCase = async () => { - const { body } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - return body; - }; - - const createComment = async (caseID: string) => { - await supertest - .post(`${CASES_URL}/${caseID}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentAlertReq) - .expect(200); - }; - - afterEach(async () => { - await deleteAllCaseItems(es); - }); - - it('should return all cases with the same alert ID attached to them', async () => { - const [case1, case2, case3] = await Promise.all([createCase(), createCase(), createCase()]); - - await Promise.all([ - createComment(case1.id), - createComment(case2.id), - createComment(case3.id), - ]); - - const { body: caseIDsWithAlert } = await supertest - .get(`${CASES_URL}/alerts/test-id`) - .expect(200); - - expect(caseIDsWithAlert.length).to.eql(3); - expect(caseIDsWithAlert).to.contain(case1.id); - expect(caseIDsWithAlert).to.contain(case2.id); - expect(caseIDsWithAlert).to.contain(case3.id); - }); - - it('should return all cases with the same alert ID when more than 100 cases', async () => { - // if there are more than 100 responses, the implementation sets the aggregation size to the - // specific value - const numCases = 102; - const createCasePromises: Array> = []; - for (let i = 0; i < numCases; i++) { - createCasePromises.push(createCase()); - } - - const cases = await Promise.all(createCasePromises); - - const commentPromises: Array> = []; - for (const caseInfo of cases) { - commentPromises.push(createComment(caseInfo.id)); - } - - await Promise.all(commentPromises); - - const { body: caseIDsWithAlert } = await supertest - .get(`${CASES_URL}/alerts/test-id`) - .expect(200); - - expect(caseIDsWithAlert.length).to.eql(numCases); - - for (const caseInfo of cases) { - expect(caseIDsWithAlert).to.contain(caseInfo.id); - } - }); - - it('should return no cases when the alert ID is not found', async () => { - const [case1, case2, case3] = await Promise.all([createCase(), createCase(), createCase()]); - - await Promise.all([ - createComment(case1.id), - createComment(case2.id), - createComment(case3.id), - ]); - - const { body: caseIDsWithAlert } = await supertest - .get(`${CASES_URL}/alerts/test-id100`) - .expect(200); - - expect(caseIDsWithAlert.length).to.eql(0); - }); - - it('should return a 302 when passing an empty alertID', async () => { - // kibana returns a 302 instead of a 400 when a url param is missing - await supertest.get(`${CASES_URL}/alerts/`).expect(302); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts deleted file mode 100644 index fece9abd5fa35..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; -import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { - createCaseAction, - createSubCase, - deleteAllCaseItems, - deleteCaseAction, - deleteCases, - deleteCasesUserActions, - deleteComments, -} from '../../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('delete_comment', () => { - afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); - }); - - it('should delete a comment', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: comment } = await supertest - .delete(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) - .set('kbn-xsrf', 'true') - .expect(204) - .send(); - expect(comment).to.eql({}); - }); - - it('unhappy path - 404s when comment belongs to different case', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body } = await supertest - .delete(`${CASES_URL}/fake-id/comments/${patchedCase.comments[0].id}`) - .set('kbn-xsrf', 'true') - .send() - .expect(404); - - expect(body.message).to.eql( - `This comment ${patchedCase.comments[0].id} does not exist in fake-id).` - ); - }); - - it('unhappy path - 404s when comment is not there', async () => { - await supertest - .delete(`${CASES_URL}/fake-id/comments/fake-id`) - .set('kbn-xsrf', 'true') - .send() - .expect(404); - }); - - it('should return a 400 when attempting to delete all comments when passing the `subCaseId` parameter', async () => { - const { body } = await supertest - .delete(`${CASES_URL}/case-id/comments?subCaseId=value`) - .set('kbn-xsrf', 'true') - .send() - .expect(400); - // make sure the failure is because of the subCaseId - expect(body.message).to.contain('subCaseId'); - }); - - it('should return a 400 when attempting to delete a single comment when passing the `subCaseId` parameter', async () => { - const { body } = await supertest - .delete(`${CASES_URL}/case-id/comments/comment-id?subCaseId=value`) - .set('kbn-xsrf', 'true') - .send() - .expect(400); - // make sure the failure is because of the subCaseId - expect(body.message).to.contain('subCaseId'); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - describe.skip('sub case comments', () => { - let actionID: string; - before(async () => { - actionID = await createCaseAction(supertest); - }); - after(async () => { - await deleteCaseAction(supertest, actionID); - }); - afterEach(async () => { - await deleteAllCaseItems(es); - }); - - it('deletes a comment from a sub case', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - await supertest - .delete( - `${CASES_URL}/${caseInfo.id}/comments/${caseInfo.comments![0].id}?subCaseId=${ - caseInfo.subCases![0].id - }` - ) - .set('kbn-xsrf', 'true') - .send() - .expect(204); - const { body } = await supertest.get( - `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` - ); - expect(body.length).to.eql(0); - }); - - it('deletes all comments from a sub case', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - let { body: allComments } = await supertest.get( - `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` - ); - expect(allComments.length).to.eql(2); - - await supertest - .delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) - .set('kbn-xsrf', 'true') - .send() - .expect(204); - - ({ body: allComments } = await supertest.get( - `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` - )); - - // no comments for the sub case - expect(allComments.length).to.eql(0); - - ({ body: allComments } = await supertest.get(`${CASES_URL}/${caseInfo.id}/comments`)); - - // no comments for the collection - expect(allComments.length).to.eql(0); - }); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts deleted file mode 100644 index 44ff1c7ebffe1..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; -import { CommentsResponse, CommentType } from '../../../../../../plugins/cases/common/api'; -import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { - createCaseAction, - createSubCase, - deleteAllCaseItems, - deleteCaseAction, - deleteCases, - deleteCasesUserActions, - deleteComments, -} from '../../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('find_comments', () => { - afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); - }); - - it('should find all case comment', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - // post 2 comments - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: caseComments } = await supertest - .get(`${CASES_URL}/${postedCase.id}/comments/_find`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(caseComments.comments).to.eql(patchedCase.comments); - }); - - it('should filter case comments', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - // post 2 comments - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ comment: 'unique', type: CommentType.user }) - .expect(200); - - const { body: caseComments } = await supertest - .get(`${CASES_URL}/${postedCase.id}/comments/_find?search=unique`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(caseComments.comments).to.eql([patchedCase.comments[1]]); - }); - - it('unhappy path - 400s when query is bad', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - await supertest - .get(`${CASES_URL}/${postedCase.id}/comments/_find?perPage=true`) - .set('kbn-xsrf', 'true') - .send() - .expect(400); - }); - - it('should return a 400 when passing the subCaseId parameter', async () => { - const { body } = await supertest - .get(`${CASES_URL}/case-id/comments/_find?search=unique&subCaseId=value`) - .set('kbn-xsrf', 'true') - .send() - .expect(400); - - expect(body.message).to.contain('subCaseId'); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - describe.skip('sub case comments', () => { - let actionID: string; - before(async () => { - actionID = await createCaseAction(supertest); - }); - after(async () => { - await deleteCaseAction(supertest, actionID); - }); - afterEach(async () => { - await deleteAllCaseItems(es); - }); - - it('finds comments for a sub case', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: subCaseComments }: { body: CommentsResponse } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseId=${caseInfo.subCases![0].id}`) - .send() - .expect(200); - expect(subCaseComments.total).to.be(2); - expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); - expect(subCaseComments.comments[1].type).to.be(CommentType.user); - }); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts deleted file mode 100644 index e73614d88ca95..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_all_comments.ts +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; -import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { - createCaseAction, - createSubCase, - deleteAllCaseItems, - deleteCaseAction, -} from '../../../../common/lib/utils'; -import { CommentType } from '../../../../../../plugins/cases/common/api'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('get_all_comments', () => { - afterEach(async () => { - await deleteAllCaseItems(es); - }); - - it('should get multiple comments for a single case', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: comments } = await supertest - .get(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(comments.length).to.eql(2); - }); - - it('should return a 400 when passing the subCaseId parameter', async () => { - const { body } = await supertest - .get(`${CASES_URL}/case-id/comments?subCaseId=value`) - .set('kbn-xsrf', 'true') - .send() - .expect(400); - - expect(body.message).to.contain('subCaseId'); - }); - - it('should return a 400 when passing the includeSubCaseComments parameter', async () => { - const { body } = await supertest - .get(`${CASES_URL}/case-id/comments?includeSubCaseComments=true`) - .set('kbn-xsrf', 'true') - .send() - .expect(400); - - expect(body.message).to.contain('includeSubCaseComments'); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - describe.skip('sub cases', () => { - let actionID: string; - before(async () => { - actionID = await createCaseAction(supertest); - }); - after(async () => { - await deleteCaseAction(supertest, actionID); - }); - - it('should get comments from a case and its sub cases', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: comments } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments?includeSubCaseComments=true`) - .expect(200); - - expect(comments.length).to.eql(2); - expect(comments[0].type).to.eql(CommentType.generatedAlert); - expect(comments[1].type).to.eql(CommentType.user); - }); - - it('should get comments from a sub cases', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - await supertest - .post(`${CASES_URL}/${caseInfo.subCases![0].id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: comments } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) - .expect(200); - - expect(comments.length).to.eql(2); - expect(comments[0].type).to.eql(CommentType.generatedAlert); - expect(comments[1].type).to.eql(CommentType.user); - }); - - it('should not find any comments for an invalid case id', async () => { - const { body } = await supertest - .get(`${CASES_URL}/fake-id/comments`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - expect(body.length).to.eql(0); - }); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts deleted file mode 100644 index a74d8f86d225b..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; -import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; -import { - createCaseAction, - createSubCase, - deleteAllCaseItems, - deleteCaseAction, -} from '../../../../common/lib/utils'; -import { CommentResponse, CommentType } from '../../../../../../plugins/cases/common/api'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('get_comment', () => { - afterEach(async () => { - await deleteAllCaseItems(es); - }); - - it('should get a comment', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: comment } = await supertest - .get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(comment).to.eql(patchedCase.comments[0]); - }); - - it('unhappy path - 404s when comment is not there', async () => { - await supertest - .get(`${CASES_URL}/fake-id/comments/fake-comment`) - .set('kbn-xsrf', 'true') - .send() - .expect(404); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - describe.skip('sub cases', () => { - let actionID: string; - before(async () => { - actionID = await createCaseAction(supertest); - }); - after(async () => { - await deleteCaseAction(supertest, actionID); - }); - it('should get a sub case comment', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - const { body: comment }: { body: CommentResponse } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.comments![0].id}`) - .expect(200); - expect(comment.type).to.be(CommentType.generatedAlert); - }); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts deleted file mode 100644 index b59a248ee07e4..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts +++ /dev/null @@ -1,414 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { omit } from 'lodash/fp'; -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; -import { CaseResponse, CommentType } from '../../../../../../plugins/cases/common/api'; -import { - defaultUser, - postCaseReq, - postCommentUserReq, - postCommentAlertReq, -} from '../../../../common/lib/mock'; -import { - createCaseAction, - createSubCase, - deleteAllCaseItems, - deleteCaseAction, - deleteCases, - deleteCasesUserActions, - deleteComments, -} from '../../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('patch_comment', () => { - afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); - }); - - it('should return a 400 when the subCaseId parameter is passed', async () => { - const { body } = await supertest - .patch(`${CASES_URL}/case-id}/comments?subCaseId=value`) - .set('kbn-xsrf', 'true') - .send({ - id: 'id', - version: 'version', - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - rule: { - id: 'id', - name: 'name', - }, - }) - .expect(400); - - expect(body.message).to.contain('subCaseId'); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - describe.skip('sub case comments', () => { - let actionID: string; - before(async () => { - actionID = await createCaseAction(supertest); - }); - after(async () => { - await deleteCaseAction(supertest, actionID); - }); - afterEach(async () => { - await deleteAllCaseItems(es); - }); - - it('patches a comment for a sub case', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - const { body: patchedSubCase }: { body: CaseResponse } = await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const newComment = 'Well I decided to update my comment. So what? Deal with it.'; - const { body: patchedSubCaseUpdatedComment } = await supertest - .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) - .set('kbn-xsrf', 'true') - .send({ - id: patchedSubCase.comments![1].id, - version: patchedSubCase.comments![1].version, - comment: newComment, - type: CommentType.user, - }) - .expect(200); - - expect(patchedSubCaseUpdatedComment.comments.length).to.be(2); - expect(patchedSubCaseUpdatedComment.comments[0].type).to.be(CommentType.generatedAlert); - expect(patchedSubCaseUpdatedComment.comments[1].type).to.be(CommentType.user); - expect(patchedSubCaseUpdatedComment.comments[1].comment).to.be(newComment); - }); - - it('fails to update the generated alert comment type', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - await supertest - .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) - .set('kbn-xsrf', 'true') - .send({ - id: caseInfo.comments![0].id, - version: caseInfo.comments![0].version, - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - rule: { - id: 'id', - name: 'name', - }, - }) - .expect(400); - }); - - it('fails to update the generated alert comment by using another generated alert comment', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - await supertest - .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) - .set('kbn-xsrf', 'true') - .send({ - id: caseInfo.comments![0].id, - version: caseInfo.comments![0].version, - type: CommentType.generatedAlert, - alerts: [{ _id: 'id1' }], - index: 'test-index', - rule: { - id: 'id', - name: 'name', - }, - }) - .expect(400); - }); - }); - - it('should patch a comment', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const newComment = 'Well I decided to update my comment. So what? Deal with it.'; - const { body } = await supertest - .patch(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - id: patchedCase.comments[0].id, - version: patchedCase.comments[0].version, - comment: newComment, - type: CommentType.user, - }) - .expect(200); - - expect(body.comments[0].comment).to.eql(newComment); - expect(body.comments[0].type).to.eql('user'); - expect(body.updated_by).to.eql(defaultUser); - }); - - it('should patch an alert', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentAlertReq) - .expect(200); - - const { body } = await supertest - .patch(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - id: patchedCase.comments[0].id, - version: patchedCase.comments[0].version, - type: CommentType.alert, - alertId: 'new-id', - index: postCommentAlertReq.index, - rule: { - id: 'id', - name: 'name', - }, - }) - .expect(200); - - expect(body.comments[0].alertId).to.eql('new-id'); - expect(body.comments[0].index).to.eql(postCommentAlertReq.index); - expect(body.comments[0].type).to.eql('alert'); - expect(body.updated_by).to.eql(defaultUser); - }); - - it('unhappy path - 404s when comment is not there', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - id: 'id', - version: 'version', - type: CommentType.user, - comment: 'comment', - }) - .expect(404); - }); - - it('unhappy path - 404s when case is not there', async () => { - await supertest - .patch(`${CASES_URL}/fake-id/comments`) - .set('kbn-xsrf', 'true') - .send({ - id: 'id', - version: 'version', - type: CommentType.user, - comment: 'comment', - }) - .expect(404); - }); - - it('unhappy path - 400s when trying to change comment type', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - await supertest - .patch(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - id: patchedCase.comments[0].id, - version: patchedCase.comments[0].version, - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - rule: { - id: 'id', - name: 'name', - }, - }) - .expect(400); - }); - - it('unhappy path - 400s when missing attributes for type user', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - await supertest - .patch(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - id: patchedCase.comments[0].id, - version: patchedCase.comments[0].version, - }) - .expect(400); - }); - - it('unhappy path - 400s when adding excess attributes for type user', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - for (const attribute of ['alertId', 'index']) { - await supertest - .patch(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - id: patchedCase.comments[0].id, - version: patchedCase.comments[0].version, - comment: 'a comment', - type: CommentType.user, - [attribute]: attribute, - }) - .expect(400); - } - }); - - it('unhappy path - 400s when missing attributes for type alert', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const allRequestAttributes = { - type: CommentType.alert, - index: 'test-index', - alertId: 'test-id', - rule: { - id: 'id', - name: 'name', - }, - }; - - for (const attribute of ['alertId', 'index']) { - const requestAttributes = omit(attribute, allRequestAttributes); - await supertest - .patch(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - id: patchedCase.comments[0].id, - version: patchedCase.comments[0].version, - ...requestAttributes, - }) - .expect(400); - } - }); - - it('unhappy path - 400s when adding excess attributes for type alert', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - for (const attribute of ['comment']) { - await supertest - .patch(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - id: patchedCase.comments[0].id, - version: patchedCase.comments[0].version, - type: CommentType.alert, - index: 'test-index', - alertId: 'test-id', - rule: { - id: 'id', - name: 'name', - }, - [attribute]: attribute, - }) - .expect(400); - } - }); - - it('unhappy path - 409s when conflict', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const newComment = 'Well I decided to update my comment. So what? Deal with it.'; - await supertest - .patch(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - id: patchedCase.comments[0].id, - version: 'version-mismatch', - type: CommentType.user, - comment: newComment, - }) - .expect(409); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts deleted file mode 100644 index c46698a0905b4..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts +++ /dev/null @@ -1,422 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { omit } from 'lodash/fp'; -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; -import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../../plugins/security_solution/common/constants'; -import { CommentsResponse, CommentType } from '../../../../../../plugins/cases/common/api'; -import { - defaultUser, - postCaseReq, - postCommentUserReq, - postCommentAlertReq, - postCollectionReq, - postCommentGenAlertReq, -} from '../../../../common/lib/mock'; -import { - createCaseAction, - createSubCase, - deleteAllCaseItems, - deleteCaseAction, - deleteCases, - deleteCasesUserActions, - deleteComments, -} from '../../../../common/lib/utils'; -import { - createSignalsIndex, - deleteSignalsIndex, - deleteAllAlerts, - getRuleForSignalTesting, - waitForRuleSuccessOrStatus, - waitForSignalsToBePresent, - getSignalsByIds, - createRule, - getQuerySignalIds, -} from '../../../../../detection_engine_api_integration/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - const es = getService('es'); - - describe('post_comment', () => { - afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); - }); - - it('should post a comment', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - expect(patchedCase.comments[0].type).to.eql(postCommentUserReq.type); - expect(patchedCase.comments[0].comment).to.eql(postCommentUserReq.comment); - expect(patchedCase.updated_by).to.eql(defaultUser); - }); - - it('should post an alert', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentAlertReq) - .expect(200); - - expect(patchedCase.comments[0].type).to.eql(postCommentAlertReq.type); - expect(patchedCase.comments[0].alertId).to.eql(postCommentAlertReq.alertId); - expect(patchedCase.comments[0].index).to.eql(postCommentAlertReq.index); - expect(patchedCase.updated_by).to.eql(defaultUser); - }); - - it('unhappy path - 400s when type is missing', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - bad: 'comment', - }) - .expect(400); - }); - - it('unhappy path - 400s when missing attributes for type user', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ type: CommentType.user }) - .expect(400); - }); - - it('unhappy path - 400s when adding excess attributes for type user', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - for (const attribute of ['alertId', 'index']) { - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ type: CommentType.user, [attribute]: attribute, comment: 'a comment' }) - .expect(400); - } - }); - - it('unhappy path - 400s when missing attributes for type alert', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const allRequestAttributes = { - type: CommentType.alert, - index: 'test-index', - alertId: 'test-id', - rule: { - id: 'id', - name: 'name', - }, - }; - - for (const attribute of ['alertId', 'index']) { - const requestAttributes = omit(attribute, allRequestAttributes); - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(requestAttributes) - .expect(400); - } - }); - - it('unhappy path - 400s when adding excess attributes for type alert', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - for (const attribute of ['comment']) { - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - type: CommentType.alert, - [attribute]: attribute, - alertId: 'test-id', - index: 'test-index', - rule: { - id: 'id', - name: 'name', - }, - }) - .expect(400); - } - }); - - it('unhappy path - 400s when case is missing', async () => { - await supertest - .post(`${CASES_URL}/not-exists/comments`) - .set('kbn-xsrf', 'true') - .send({ - bad: 'comment', - }) - .expect(400); - }); - - it('unhappy path - 400s when adding an alert to a closed case', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - status: 'closed', - }, - ], - }) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentAlertReq) - .expect(400); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - it.skip('400s when adding an alert to a collection case', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCollectionReq) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentAlertReq) - .expect(400); - }); - - it('400s when adding a generated alert to an individual case', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentGenAlertReq) - .expect(400); - }); - - describe('alerts', () => { - beforeEach(async () => { - await esArchiver.load('auditbeat/hosts'); - await createSignalsIndex(supertest); - }); - - afterEach(async () => { - await deleteSignalsIndex(supertest); - await deleteAllAlerts(supertest); - await esArchiver.unload('auditbeat/hosts'); - }); - - it('should change the status of the alert if sync alert is on', async () => { - const rule = getRuleForSignalTesting(['auditbeat-*']); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - status: 'in-progress', - }, - ], - }) - .expect(200); - - const { id } = await createRule(supertest, rule); - await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); - const signals = await getSignalsByIds(supertest, [id]); - - const alert = signals.hits.hits[0]; - expect(alert._source.signal.status).eql('open'); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - alertId: alert._id, - index: alert._index, - rule: { - id: 'id', - name: 'name', - }, - type: CommentType.alert, - }) - .expect(200); - - const { body: updatedAlert } = await supertest - .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) - .set('kbn-xsrf', 'true') - .send(getQuerySignalIds([alert._id])) - .expect(200); - - expect(updatedAlert.hits.hits[0]._source.signal.status).eql('in-progress'); - }); - - it('should NOT change the status of the alert if sync alert is off', async () => { - const rule = getRuleForSignalTesting(['auditbeat-*']); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ ...postCaseReq, settings: { syncAlerts: false } }) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - status: 'in-progress', - }, - ], - }) - .expect(200); - - const { id } = await createRule(supertest, rule); - await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); - const signals = await getSignalsByIds(supertest, [id]); - - const alert = signals.hits.hits[0]; - expect(alert._source.signal.status).eql('open'); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - alertId: alert._id, - index: alert._index, - rule: { - id: 'id', - name: 'name', - }, - type: CommentType.alert, - }) - .expect(200); - - const { body: updatedAlert } = await supertest - .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) - .set('kbn-xsrf', 'true') - .send(getQuerySignalIds([alert._id])) - .expect(200); - - expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); - }); - }); - - it('should return a 400 when passing the subCaseId', async () => { - const { body } = await supertest - .post(`${CASES_URL}/case-id/comments?subCaseId=value`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(400); - expect(body.message).to.contain('subCaseId'); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - describe.skip('sub case comments', () => { - let actionID: string; - before(async () => { - actionID = await createCaseAction(supertest); - }); - after(async () => { - await deleteCaseAction(supertest, actionID); - }); - afterEach(async () => { - await deleteAllCaseItems(es); - }); - - it('posts a new comment for a sub case', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - // create another sub case just to make sure we get the right comments - await createSubCase({ supertest, actionID }); - await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: subCaseComments }: { body: CommentsResponse } = await supertest - .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseId=${caseInfo.subCases![0].id}`) - .send() - .expect(200); - expect(subCaseComments.total).to.be(2); - expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); - expect(subCaseComments.comments[1].type).to.be(CommentType.user); - }); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts deleted file mode 100644 index 706fded263282..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../plugins/cases/common/constants'; -import { postCaseReq, postCommentUserReq } from '../../../common/lib/mock'; -import { - createCaseAction, - createSubCase, - deleteAllCaseItems, - deleteCaseAction, - deleteCases, - deleteCasesUserActions, - deleteComments, -} from '../../../common/lib/utils'; -import { getSubCaseDetailsUrl } from '../../../../../plugins/cases/common/api/helpers'; -import { CaseResponse } from '../../../../../plugins/cases/common/api'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('delete_cases', () => { - afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteCasesUserActions(es); - }); - - it('should delete a case', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body } = await supertest - .delete(`${CASES_URL}?ids=["${postedCase.id}"]`) - .set('kbn-xsrf', 'true') - .send() - .expect(204); - - expect(body).to.eql({}); - }); - - it(`should delete a case's comments when that case gets deleted`, async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - await supertest - .get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - await supertest - .delete(`${CASES_URL}?ids=["${postedCase.id}"]`) - .set('kbn-xsrf', 'true') - .send() - .expect(204); - - await supertest - .get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) - .set('kbn-xsrf', 'true') - .send() - .expect(404); - }); - - it('unhappy path - 404s when case is not there', async () => { - await supertest - .delete(`${CASES_URL}?ids=["fake-id"]`) - .set('kbn-xsrf', 'true') - .send() - .expect(404); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - describe.skip('sub cases', () => { - let actionID: string; - before(async () => { - actionID = await createCaseAction(supertest); - }); - after(async () => { - await deleteCaseAction(supertest, actionID); - }); - afterEach(async () => { - await deleteAllCaseItems(es); - }); - - it('should delete the sub cases when deleting a collection', async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - expect(caseInfo.subCases![0].id).to.not.eql(undefined); - - const { body } = await supertest - .delete(`${CASES_URL}?ids=["${caseInfo.id}"]`) - .set('kbn-xsrf', 'true') - .send() - .expect(204); - - expect(body).to.eql({}); - await supertest - .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) - .send() - .expect(404); - }); - - it(`should delete a sub case's comments when that case gets deleted`, async () => { - const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); - expect(caseInfo.subCases![0].id).to.not.eql(undefined); - - // there should be two comments on the sub case now - const { body: patchedCaseWithSubCase }: { body: CaseResponse } = await supertest - .post(`${CASES_URL}/${caseInfo.id}/comments`) - .set('kbn-xsrf', 'true') - .query({ subCaseId: caseInfo.subCases![0].id }) - .send(postCommentUserReq) - .expect(200); - - const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${ - patchedCaseWithSubCase.comments![1].id - }`; - // make sure we can get the second comment - await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200); - - await supertest - .delete(`${CASES_URL}?ids=["${caseInfo.id}"]`) - .set('kbn-xsrf', 'true') - .send() - .expect(204); - - await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(404); - }); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts deleted file mode 100644 index 0166ad2e8ee7f..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts +++ /dev/null @@ -1,667 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 supertestAsPromised from 'supertest-as-promised'; -import type { ApiResponse, estypes } from '@elastic/elasticsearch'; -import { FtrProviderContext } from '../../../common/ftr_provider_context'; - -import { CASES_URL, SUB_CASES_PATCH_DEL_URL } from '../../../../../plugins/cases/common/constants'; -import { postCaseReq, postCommentUserReq, findCasesResp } from '../../../common/lib/mock'; -import { - deleteAllCaseItems, - createSubCase, - setStatus, - CreateSubCaseResp, - createCaseAction, - deleteCaseAction, -} from '../../../common/lib/utils'; -import { CasesFindResponse, CaseStatuses, CaseType } from '../../../../../plugins/cases/common/api'; - -interface CaseAttributes { - cases: { - title: string; - }; -} - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - describe('find_cases', () => { - describe('basic tests', () => { - afterEach(async () => { - await deleteAllCaseItems(es); - }); - - it('should return empty response', async () => { - const { body } = await supertest - .get(`${CASES_URL}/_find`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql(findCasesResp); - }); - - it('should return cases', async () => { - const { body: a } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: b } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: c } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql({ - ...findCasesResp, - total: 3, - cases: [a, b, c], - count_open_cases: 3, - }); - }); - - it('filters by tags', async () => { - await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq); - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ ...postCaseReq, tags: ['unique'] }) - .expect(200); - - const { body } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc&tags=unique`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql({ - ...findCasesResp, - total: 1, - cases: [postedCase], - count_open_cases: 1, - }); - }); - - it('filters by status', async () => { - const { body: openCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq); - - const { body: toCloseCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: toCloseCase.id, - version: toCloseCase.version, - status: 'closed', - }, - ], - }) - .expect(200); - - const { body } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc&status=open`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql({ - ...findCasesResp, - total: 1, - cases: [openCase], - count_open_cases: 1, - count_closed_cases: 1, - count_in_progress_cases: 0, - }); - }); - - it('filters by reporters', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq); - - const { body } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc&reporters=elastic`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql({ - ...findCasesResp, - total: 1, - cases: [postedCase], - count_open_cases: 1, - }); - }); - - it('correctly counts comments', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - // post 2 comments - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql({ - ...findCasesResp, - total: 1, - cases: [ - { - ...patchedCase, - comments: [], - totalComment: 2, - }, - ], - count_open_cases: 1, - }); - }); - - it('correctly counts open/closed/in-progress', async () => { - await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq); - - const { body: inProgreeCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - status: 'closed', - }, - ], - }) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: inProgreeCase.id, - version: inProgreeCase.version, - status: 'in-progress', - }, - ], - }) - .expect(200); - - const { body } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body.count_open_cases).to.eql(1); - expect(body.count_closed_cases).to.eql(1); - expect(body.count_in_progress_cases).to.eql(1); - }); - - it('unhappy path - 400s when bad query supplied', async () => { - await supertest - .get(`${CASES_URL}/_find?perPage=true`) - .set('kbn-xsrf', 'true') - .send() - .expect(400); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - describe.skip('stats with sub cases', () => { - let collection: CreateSubCaseResp; - let actionID: string; - before(async () => { - actionID = await createCaseAction(supertest); - }); - after(async () => { - await deleteCaseAction(supertest, actionID); - }); - beforeEach(async () => { - // create a collection with a sub case that is marked as open - collection = await createSubCase({ supertest, actionID }); - - const [, , { body: toCloseCase }] = await Promise.all([ - // set the sub case to in-progress - setStatus({ - supertest, - cases: [ - { - id: collection.newSubCaseInfo.subCases![0].id, - version: collection.newSubCaseInfo.subCases![0].version, - status: CaseStatuses['in-progress'], - }, - ], - type: 'sub_case', - }), - // create two cases that are both open - supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), - supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), - ]); - - // set the third case to closed - await setStatus({ - supertest, - cases: [ - { - id: toCloseCase.id, - version: toCloseCase.version, - status: CaseStatuses.closed, - }, - ], - type: 'case', - }); - }); - it('correctly counts stats without using a filter', async () => { - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc`) - .expect(200); - - expect(body.total).to.eql(3); - expect(body.count_closed_cases).to.eql(1); - expect(body.count_open_cases).to.eql(1); - expect(body.count_in_progress_cases).to.eql(1); - }); - - it('correctly counts stats with a filter for open cases', async () => { - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc&status=open`) - .expect(200); - - expect(body.cases.length).to.eql(1); - - // since we're filtering on status and the collection only has an in-progress case, it should only return the - // individual case that has the open status and no collections - // ENABLE_CASE_CONNECTOR: this value is not correct because it includes a collection - // that does not have an open case. This is a known issue and will need to be resolved - // when this issue is addressed: https://github.com/elastic/kibana/issues/94115 - expect(body.total).to.eql(2); - expect(body.count_closed_cases).to.eql(1); - expect(body.count_open_cases).to.eql(1); - expect(body.count_in_progress_cases).to.eql(1); - }); - - it('correctly counts stats with a filter for individual cases', async () => { - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.individual}`) - .expect(200); - - expect(body.total).to.eql(2); - expect(body.count_closed_cases).to.eql(1); - expect(body.count_open_cases).to.eql(1); - expect(body.count_in_progress_cases).to.eql(0); - }); - - it('correctly counts stats with a filter for collection cases with multiple sub cases', async () => { - // this will force the first sub case attached to the collection to be closed - // so we'll have one closed sub case and one open sub case - await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}`) - .expect(200); - - expect(body.total).to.eql(1); - expect(body.cases[0].subCases?.length).to.eql(2); - expect(body.count_closed_cases).to.eql(1); - expect(body.count_open_cases).to.eql(1); - expect(body.count_in_progress_cases).to.eql(0); - }); - - it('correctly counts stats with a filter for collection and open cases with multiple sub cases', async () => { - // this will force the first sub case attached to the collection to be closed - // so we'll have one closed sub case and one open sub case - await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); - const { body }: { body: CasesFindResponse } = await supertest - .get( - `${CASES_URL}/_find?sortOrder=asc&type=${CaseType.collection}&status=${CaseStatuses.open}` - ) - .expect(200); - - expect(body.total).to.eql(1); - expect(body.cases[0].subCases?.length).to.eql(1); - expect(body.count_closed_cases).to.eql(1); - expect(body.count_open_cases).to.eql(1); - expect(body.count_in_progress_cases).to.eql(0); - }); - - it('correctly counts stats including a collection without sub cases when not filtering on status', async () => { - // delete the sub case on the collection so that it doesn't have any sub cases - await supertest - .delete( - `${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCases![0].id}"]` - ) - .set('kbn-xsrf', 'true') - .send() - .expect(204); - - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc`) - .expect(200); - - // it should include the collection without sub cases because we did not pass in a filter on status - expect(body.total).to.eql(3); - expect(body.count_closed_cases).to.eql(1); - expect(body.count_open_cases).to.eql(1); - expect(body.count_in_progress_cases).to.eql(0); - }); - - it('correctly counts stats including a collection without sub cases when filtering on tags', async () => { - // delete the sub case on the collection so that it doesn't have any sub cases - await supertest - .delete( - `${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCases![0].id}"]` - ) - .set('kbn-xsrf', 'true') - .send() - .expect(204); - - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc&tags=defacement`) - .expect(200); - - // it should include the collection without sub cases because we did not pass in a filter on status - expect(body.total).to.eql(3); - expect(body.count_closed_cases).to.eql(1); - expect(body.count_open_cases).to.eql(1); - expect(body.count_in_progress_cases).to.eql(0); - }); - - it('does not return collections without sub cases matching the requested status', async () => { - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc&status=closed`) - .expect(200); - - expect(body.cases.length).to.eql(1); - // it should not include the collection that has a sub case as in-progress - // ENABLE_CASE_CONNECTOR: this value is not correct because it includes collections. This short term - // fix for when sub cases are not enabled. When the feature is completed the _find API - // will need to be fixed as explained in this ticket: https://github.com/elastic/kibana/issues/94115 - expect(body.total).to.eql(2); - expect(body.count_closed_cases).to.eql(1); - expect(body.count_open_cases).to.eql(1); - expect(body.count_in_progress_cases).to.eql(1); - }); - - it('does not return empty collections when filtering on status', async () => { - // delete the sub case on the collection so that it doesn't have any sub cases - await supertest - .delete( - `${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCases![0].id}"]` - ) - .set('kbn-xsrf', 'true') - .send() - .expect(204); - - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find?sortOrder=asc&status=closed`) - .expect(200); - - expect(body.cases.length).to.eql(1); - - // ENABLE_CASE_CONNECTOR: this value is not correct because it includes collections. This short term - // fix for when sub cases are not enabled. When the feature is completed the _find API - // will need to be fixed as explained in this ticket: https://github.com/elastic/kibana/issues/94115 - expect(body.total).to.eql(2); - expect(body.count_closed_cases).to.eql(1); - expect(body.count_open_cases).to.eql(1); - expect(body.count_in_progress_cases).to.eql(0); - }); - }); - }); - - describe('find_cases pagination', () => { - const numCases = 10; - before(async () => { - await createCasesWithTitleAsNumber(numCases); - }); - - after(async () => { - await deleteAllCaseItems(es); - }); - - const createCasesWithTitleAsNumber = async (total: number) => { - const responsePromises: supertestAsPromised.Test[] = []; - for (let i = 0; i < total; i++) { - // this doesn't guarantee that the cases will be created in order that the for-loop executes, - // for example case with title '2', could be created before the case with title '1' since we're doing a promise all here - // A promise all is just much faster than doing it one by one which would have guaranteed that the cases are - // created in the order that the for-loop executes - responsePromises.push( - supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ ...postCaseReq, title: `${i}` }) - ); - } - const responses = await Promise.all(responsePromises); - return responses.map((response) => response.body); - }; - - /** - * This is used to retrieve all the cases in the same sorted order that we're expecting them to come back via the - * _find API so that we have a more true comparison instead of using the _find API to get all the cases which - * could mangle the results if the implementation had a bug. - * - * Ideally we could enforce how the cases are created in reasonable time, waiting for each api call to finish takes - * around 30 seconds which seemed too slow - */ - const getAllCasesSortedByCreatedAtAsc = async () => { - const cases: ApiResponse> = await es.search({ - index: '.kibana', - body: { - size: 10000, - sort: [{ 'cases.created_at': { unmapped_type: 'date', order: 'asc' } }], - query: { - term: { type: 'cases' }, - }, - }, - }); - return cases.body.hits.hits.map((hit) => hit._source); - }; - - it('returns the correct total when perPage is less than the total', async () => { - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find`) - .query({ - sortOrder: 'asc', - page: 1, - perPage: 5, - }) - .set('kbn-xsrf', 'true') - .expect(200); - - expect(body.cases.length).to.eql(5); - expect(body.total).to.eql(10); - expect(body.page).to.eql(1); - expect(body.per_page).to.eql(5); - expect(body.count_open_cases).to.eql(10); - expect(body.count_closed_cases).to.eql(0); - expect(body.count_in_progress_cases).to.eql(0); - }); - - it('returns the correct total when perPage is greater than the total', async () => { - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find`) - .query({ - sortOrder: 'asc', - page: 1, - perPage: 11, - }) - .set('kbn-xsrf', 'true') - .expect(200); - - expect(body.total).to.eql(10); - expect(body.page).to.eql(1); - expect(body.per_page).to.eql(11); - expect(body.cases.length).to.eql(10); - expect(body.count_open_cases).to.eql(10); - expect(body.count_closed_cases).to.eql(0); - expect(body.count_in_progress_cases).to.eql(0); - }); - - it('returns the correct total when perPage is equal to the total', async () => { - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find`) - .query({ - sortOrder: 'asc', - page: 1, - perPage: 10, - }) - .set('kbn-xsrf', 'true') - .expect(200); - - expect(body.total).to.eql(10); - expect(body.page).to.eql(1); - expect(body.per_page).to.eql(10); - expect(body.cases.length).to.eql(10); - expect(body.count_open_cases).to.eql(10); - expect(body.count_closed_cases).to.eql(0); - expect(body.count_in_progress_cases).to.eql(0); - }); - - it('returns the second page of results', async () => { - const perPage = 5; - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find`) - .query({ - sortOrder: 'asc', - page: 2, - perPage, - }) - .set('kbn-xsrf', 'true') - .expect(200); - - expect(body.total).to.eql(10); - expect(body.page).to.eql(2); - expect(body.per_page).to.eql(5); - expect(body.cases.length).to.eql(5); - expect(body.count_open_cases).to.eql(10); - expect(body.count_closed_cases).to.eql(0); - expect(body.count_in_progress_cases).to.eql(0); - - const allCases = await getAllCasesSortedByCreatedAtAsc(); - - body.cases.map((caseInfo, index) => { - // we started on the second page of 10 cases with a perPage of 5, so the first case should 0 + 5 (index + perPage) - expect(caseInfo.title).to.eql(allCases[index + perPage]?.cases.title); - }); - }); - - it('paginates with perPage of 2 through 10 total cases', async () => { - const total = 10; - const perPage = 2; - - // it's less than or equal here because the page starts at 1, so page 5 is a valid page number - // and should have case titles 9, and 10 - for (let currentPage = 1; currentPage <= total / perPage; currentPage++) { - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find`) - .query({ - sortOrder: 'asc', - page: currentPage, - perPage, - }) - .set('kbn-xsrf', 'true') - .expect(200); - - expect(body.total).to.eql(total); - expect(body.page).to.eql(currentPage); - expect(body.per_page).to.eql(perPage); - expect(body.cases.length).to.eql(perPage); - expect(body.count_open_cases).to.eql(total); - expect(body.count_closed_cases).to.eql(0); - expect(body.count_in_progress_cases).to.eql(0); - - const allCases = await getAllCasesSortedByCreatedAtAsc(); - - body.cases.map((caseInfo, index) => { - // for page 1, the cases tiles should be 0,1,2 for page 2: 3,4,5 etc (assuming the titles were sorted - // correctly) - expect(caseInfo.title).to.eql( - allCases[index + perPage * (currentPage - 1)]?.cases.title - ); - }); - } - }); - - it('retrieves the last three cases', async () => { - const { body }: { body: CasesFindResponse } = await supertest - .get(`${CASES_URL}/_find`) - .query({ - sortOrder: 'asc', - // this should skip the first 7 cases and only return the last 3 - page: 2, - perPage: 7, - }) - .set('kbn-xsrf', 'true') - .expect(200); - - expect(body.total).to.eql(10); - expect(body.page).to.eql(2); - expect(body.per_page).to.eql(7); - expect(body.cases.length).to.eql(3); - expect(body.count_open_cases).to.eql(10); - expect(body.count_closed_cases).to.eql(0); - expect(body.count_in_progress_cases).to.eql(0); - }); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/get_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/get_case.ts deleted file mode 100644 index 43bd7a616e729..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/get_case.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../plugins/cases/common/constants'; -import { - postCaseReq, - postCaseResp, - removeServerGeneratedPropertiesFromCase, -} from '../../../common/lib/mock'; -import { deleteCases } from '../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('get_case', () => { - afterEach(async () => { - await deleteCases(es); - }); - - it('should return a case', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body } = await supertest - .get(`${CASES_URL}/${postedCase.id}`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - const data = removeServerGeneratedPropertiesFromCase(body); - expect(data).to.eql(postCaseResp(postedCase.id)); - }); - - it('should return a 400 when passing the includeSubCaseComments', async () => { - const { body } = await supertest - .get(`${CASES_URL}/case-id?includeSubCaseComments=true`) - .set('kbn-xsrf', 'true') - .send() - .expect(400); - - expect(body.message).to.contain('subCaseId'); - }); - - it('unhappy path - 404s when case is not there', async () => { - await supertest.get(`${CASES_URL}/fake-id`).set('kbn-xsrf', 'true').send().expect(404); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts deleted file mode 100644 index f43b47da19ade..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts +++ /dev/null @@ -1,935 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../plugins/cases/common/constants'; -import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../plugins/security_solution/common/constants'; -import { - CasesResponse, - CaseStatuses, - CaseType, - CommentType, -} from '../../../../../plugins/cases/common/api'; -import { - defaultUser, - postCaseReq, - postCaseResp, - postCollectionReq, - postCommentAlertReq, - postCommentUserReq, - removeServerGeneratedPropertiesFromCase, -} from '../../../common/lib/mock'; -import { deleteAllCaseItems, getSignalsWithES, setStatus } from '../../../common/lib/utils'; -import { - createSignalsIndex, - deleteSignalsIndex, - deleteAllAlerts, - getRuleForSignalTesting, - waitForRuleSuccessOrStatus, - waitForSignalsToBePresent, - getSignalsByIds, - createRule, - getQuerySignalIds, -} from '../../../../detection_engine_api_integration/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - const es = getService('es'); - - describe('patch_cases', () => { - afterEach(async () => { - await deleteAllCaseItems(es); - }); - - it('should patch a case', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCases } = await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - status: 'closed', - }, - ], - }) - .expect(200); - - const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); - expect(data).to.eql({ - ...postCaseResp(postedCase.id), - closed_by: defaultUser, - status: 'closed', - updated_by: defaultUser, - }); - }); - - it('should patch a case with new connector', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCases } = await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - connector: { - id: 'jira', - name: 'Jira', - type: '.jira', - fields: { issueType: 'Task', priority: null, parent: null }, - }, - }, - ], - }) - .expect(200); - - const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); - expect(data).to.eql({ - ...postCaseResp(postedCase.id), - connector: { - id: 'jira', - name: 'Jira', - type: '.jira', - fields: { issueType: 'Task', priority: null, parent: null }, - }, - updated_by: defaultUser, - }); - }); - - it('unhappy path - 404s when case is not there', async () => { - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: 'not-real', - version: 'version', - status: 'closed', - }, - ], - }) - .expect(404); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - it.skip('should 400 and not allow converting a collection back to an individual case', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCollectionReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - type: CaseType.individual, - }, - ], - }) - .expect(400); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - it.skip('should allow converting an individual case to a collection when it does not have alerts', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: patchedCase.id, - version: patchedCase.version, - type: CaseType.collection, - }, - ], - }) - .expect(200); - }); - - it('should 400 when attempting to update an individual case to a collection when it has alerts attached to it', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { body: patchedCase } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentAlertReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: patchedCase.id, - version: patchedCase.version, - type: CaseType.collection, - }, - ], - }) - .expect(400); - }); - - it('should 400 when attempting to update the case type when the case connector feature is disabled', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - type: CaseType.collection, - }, - ], - }) - .expect(400); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - it.skip("should 400 when attempting to update a collection case's status", async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCollectionReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - status: 'closed', - }, - ], - }) - .expect(400); - }); - - it('unhappy path - 406s when excess data sent', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - badKey: 'closed', - }, - ], - }) - .expect(406); - }); - - it('unhappy path - 400s when bad data sent', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - status: true, - }, - ], - }) - .expect(400); - }); - - it('unhappy path - 400s when unsupported status sent', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - status: 'not-supported', - }, - ], - }) - .expect(400); - }); - - it('unhappy path - 400s when bad connector type sent', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - connector: { id: 'none', name: 'none', type: '.not-exists', fields: null }, - }, - ], - }) - .expect(400); - }); - - it('unhappy path - 400s when bad connector sent', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - connector: { - id: 'none', - name: 'none', - type: '.jira', - fields: { unsupported: 'value' }, - }, - }, - ], - }) - .expect(400); - }); - - it('unhappy path - 409s when conflict', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(`${CASES_URL}`) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: 'version', - status: 'closed', - }, - ], - }) - .expect(409); - }); - - describe('alerts', () => { - describe('esArchiver', () => { - const defaultSignalsIndex = '.siem-signals-default-000001'; - - beforeEach(async () => { - await esArchiver.load('cases/signals/default'); - }); - afterEach(async () => { - await esArchiver.unload('cases/signals/default'); - await deleteAllCaseItems(es); - }); - - it('should update the status of multiple alerts attached to multiple cases', async () => { - const signalID = '5f2b9ec41f8febb1c06b5d1045aeabb9874733b7617e88a370510f2fb3a41a5d'; - const signalID2 = '4d0f4b1533e46b66b43bdd0330d23f39f2cf42a7253153270e38d30cce9ff0c6'; - - const { body: individualCase1 } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - settings: { - syncAlerts: false, - }, - }); - - const { body: updatedInd1WithComment } = await supertest - .post(`${CASES_URL}/${individualCase1.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - alertId: signalID, - index: defaultSignalsIndex, - rule: { id: 'test-rule-id', name: 'test-index-id' }, - type: CommentType.alert, - }) - .expect(200); - - const { body: individualCase2 } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - settings: { - syncAlerts: false, - }, - }); - - const { body: updatedInd2WithComment } = await supertest - .post(`${CASES_URL}/${individualCase2.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - alertId: signalID2, - index: defaultSignalsIndex, - rule: { id: 'test-rule-id', name: 'test-index-id' }, - type: CommentType.alert, - }) - .expect(200); - - await es.indices.refresh({ index: defaultSignalsIndex }); - - let signals = await getSignalsWithES({ - es, - indices: defaultSignalsIndex, - ids: [signalID, signalID2], - }); - - // There should be no change in their status since syncing is disabled - expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be( - CaseStatuses.open - ); - expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be( - CaseStatuses.open - ); - - const updatedIndWithStatus: CasesResponse = (await setStatus({ - supertest, - cases: [ - { - id: updatedInd1WithComment.id, - version: updatedInd1WithComment.version, - status: CaseStatuses.closed, - }, - { - id: updatedInd2WithComment.id, - version: updatedInd2WithComment.version, - status: CaseStatuses['in-progress'], - }, - ], - type: 'case', - })) as CasesResponse; - - await es.indices.refresh({ index: defaultSignalsIndex }); - - signals = await getSignalsWithES({ - es, - indices: defaultSignalsIndex, - ids: [signalID, signalID2], - }); - - // There should still be no change in their status since syncing is disabled - expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be( - CaseStatuses.open - ); - expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be( - CaseStatuses.open - ); - - // turn on the sync settings - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: updatedIndWithStatus.map((caseInfo) => ({ - id: caseInfo.id, - version: caseInfo.version, - settings: { syncAlerts: true }, - })), - }) - .expect(200); - - await es.indices.refresh({ index: defaultSignalsIndex }); - - signals = await getSignalsWithES({ - es, - indices: defaultSignalsIndex, - ids: [signalID, signalID2], - }); - - // alerts should be updated now that the - expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be( - CaseStatuses.closed - ); - expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be( - CaseStatuses['in-progress'] - ); - }); - }); - - describe('esArchiver', () => { - const defaultSignalsIndex = '.siem-signals-default-000001'; - - beforeEach(async () => { - await esArchiver.load('cases/signals/duplicate_ids'); - }); - afterEach(async () => { - await esArchiver.unload('cases/signals/duplicate_ids'); - await deleteAllCaseItems(es); - }); - - it('should not update the status of duplicate alert ids in separate indices', async () => { - const getSignals = async () => { - return getSignalsWithES({ - es, - indices: [defaultSignalsIndex, signalsIndex2], - ids: [signalIDInFirstIndex, signalIDInSecondIndex], - }); - }; - - // this id exists only in .siem-signals-default-000001 - const signalIDInFirstIndex = - 'cae78067e65582a3b277c1ad46ba3cb29044242fe0d24bbf3fcde757fdd31d1c'; - // This id exists in both .siem-signals-default-000001 and .siem-signals-default-000002 - const signalIDInSecondIndex = 'duplicate-signal-id'; - const signalsIndex2 = '.siem-signals-default-000002'; - - const { body: individualCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - settings: { - syncAlerts: false, - }, - }); - - const { body: updatedIndWithComment } = await supertest - .post(`${CASES_URL}/${individualCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - alertId: signalIDInFirstIndex, - index: defaultSignalsIndex, - rule: { id: 'test-rule-id', name: 'test-index-id' }, - type: CommentType.alert, - }) - .expect(200); - - const { body: updatedIndWithComment2 } = await supertest - .post(`${CASES_URL}/${updatedIndWithComment.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - alertId: signalIDInSecondIndex, - index: signalsIndex2, - rule: { id: 'test-rule-id', name: 'test-index-id' }, - type: CommentType.alert, - }) - .expect(200); - - await es.indices.refresh({ index: defaultSignalsIndex }); - - let signals = await getSignals(); - // There should be no change in their status since syncing is disabled - expect( - signals.get(defaultSignalsIndex)?.get(signalIDInFirstIndex)?._source?.signal.status - ).to.be(CaseStatuses.open); - expect( - signals.get(signalsIndex2)?.get(signalIDInSecondIndex)?._source?.signal.status - ).to.be(CaseStatuses.open); - - const updatedIndWithStatus: CasesResponse = (await setStatus({ - supertest, - cases: [ - { - id: updatedIndWithComment2.id, - version: updatedIndWithComment2.version, - status: CaseStatuses.closed, - }, - ], - type: 'case', - })) as CasesResponse; - - await es.indices.refresh({ index: defaultSignalsIndex }); - - signals = await getSignals(); - - // There should still be no change in their status since syncing is disabled - expect( - signals.get(defaultSignalsIndex)?.get(signalIDInFirstIndex)?._source?.signal.status - ).to.be(CaseStatuses.open); - expect( - signals.get(signalsIndex2)?.get(signalIDInSecondIndex)?._source?.signal.status - ).to.be(CaseStatuses.open); - - // turn on the sync settings - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: updatedIndWithStatus[0].id, - version: updatedIndWithStatus[0].version, - settings: { syncAlerts: true }, - }, - ], - }) - .expect(200); - await es.indices.refresh({ index: defaultSignalsIndex }); - - signals = await getSignals(); - - // alerts should be updated now that the - expect( - signals.get(defaultSignalsIndex)?.get(signalIDInFirstIndex)?._source?.signal.status - ).to.be(CaseStatuses.closed); - expect( - signals.get(signalsIndex2)?.get(signalIDInSecondIndex)?._source?.signal.status - ).to.be(CaseStatuses.closed); - - // the duplicate signal id in the other index should not be affect (so its status should be open) - expect( - signals.get(defaultSignalsIndex)?.get(signalIDInSecondIndex)?._source?.signal.status - ).to.be(CaseStatuses.open); - }); - }); - - describe('detections rule', () => { - beforeEach(async () => { - await esArchiver.load('auditbeat/hosts'); - await createSignalsIndex(supertest); - }); - - afterEach(async () => { - await deleteSignalsIndex(supertest); - await deleteAllAlerts(supertest); - await esArchiver.unload('auditbeat/hosts'); - }); - - it('updates alert status when the status is updated and syncAlerts=true', async () => { - const rule = getRuleForSignalTesting(['auditbeat-*']); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { id } = await createRule(supertest, rule); - await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); - const signals = await getSignalsByIds(supertest, [id]); - - const alert = signals.hits.hits[0]; - expect(alert._source.signal.status).eql('open'); - - const { body: caseUpdated } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - alertId: alert._id, - index: alert._index, - rule: { - id: 'id', - name: 'name', - }, - type: CommentType.alert, - }) - .expect(200); - - await es.indices.refresh({ index: alert._index }); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: caseUpdated.id, - version: caseUpdated.version, - status: 'in-progress', - }, - ], - }) - .expect(200); - - // force a refresh on the index that the signal is stored in so that we can search for it and get the correct - // status - await es.indices.refresh({ index: alert._index }); - - const { body: updatedAlert } = await supertest - .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) - .set('kbn-xsrf', 'true') - .send(getQuerySignalIds([alert._id])) - .expect(200); - - expect(updatedAlert.hits.hits[0]._source.signal.status).eql('in-progress'); - }); - - it('does NOT updates alert status when the status is updated and syncAlerts=false', async () => { - const rule = getRuleForSignalTesting(['auditbeat-*']); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ ...postCaseReq, settings: { syncAlerts: false } }) - .expect(200); - - const { id } = await createRule(supertest, rule); - await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); - const signals = await getSignalsByIds(supertest, [id]); - - const alert = signals.hits.hits[0]; - expect(alert._source.signal.status).eql('open'); - - const { body: caseUpdated } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - alertId: alert._id, - index: alert._index, - type: CommentType.alert, - rule: { - id: 'id', - name: 'name', - }, - }) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: caseUpdated.id, - version: caseUpdated.version, - status: 'in-progress', - }, - ], - }) - .expect(200); - - const { body: updatedAlert } = await supertest - .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) - .set('kbn-xsrf', 'true') - .send(getQuerySignalIds([alert._id])) - .expect(200); - - expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); - }); - - it('it updates alert status when syncAlerts is turned on', async () => { - const rule = getRuleForSignalTesting(['auditbeat-*']); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ ...postCaseReq, settings: { syncAlerts: false } }) - .expect(200); - - const { id } = await createRule(supertest, rule); - await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); - const signals = await getSignalsByIds(supertest, [id]); - - const alert = signals.hits.hits[0]; - expect(alert._source.signal.status).eql('open'); - - const { body: caseUpdated } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - alertId: alert._id, - index: alert._index, - rule: { - id: 'id', - name: 'name', - }, - type: CommentType.alert, - }) - .expect(200); - - // Update the status of the case with sync alerts off - const { body: caseStatusUpdated } = await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: caseUpdated.id, - version: caseUpdated.version, - status: 'in-progress', - }, - ], - }) - .expect(200); - - // Turn sync alerts on - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: caseStatusUpdated[0].id, - version: caseStatusUpdated[0].version, - settings: { syncAlerts: true }, - }, - ], - }) - .expect(200); - - // refresh the index because syncAlerts was set to true so the alert's status should have been updated - await es.indices.refresh({ index: alert._index }); - - const { body: updatedAlert } = await supertest - .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) - .set('kbn-xsrf', 'true') - .send(getQuerySignalIds([alert._id])) - .expect(200); - - expect(updatedAlert.hits.hits[0]._source.signal.status).eql('in-progress'); - }); - - it('it does NOT updates alert status when syncAlerts is turned off', async () => { - const rule = getRuleForSignalTesting(['auditbeat-*']); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const { id } = await createRule(supertest, rule); - await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); - const signals = await getSignalsByIds(supertest, [id]); - - const alert = signals.hits.hits[0]; - expect(alert._source.signal.status).eql('open'); - - const { body: caseUpdated } = await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send({ - alertId: alert._id, - index: alert._index, - type: CommentType.alert, - rule: { - id: 'id', - name: 'name', - }, - }) - .expect(200); - - // Turn sync alerts off - const { body: caseSettingsUpdated } = await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: caseUpdated.id, - version: caseUpdated.version, - settings: { syncAlerts: false }, - }, - ], - }) - .expect(200); - - // Update the status of the case with sync alerts off - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: caseSettingsUpdated[0].id, - version: caseSettingsUpdated[0].version, - status: 'in-progress', - }, - ], - }) - .expect(200); - - const { body: updatedAlert } = await supertest - .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) - .set('kbn-xsrf', 'true') - .send(getQuerySignalIds([alert._id])) - .expect(200); - - expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); - }); - }); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/post_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/post_case.ts deleted file mode 100644 index 5de5644ccf68a..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/post_case.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../common/ftr_provider_context'; - -import { CASES_URL } from '../../../../../plugins/cases/common/constants'; -import { - postCaseReq, - postCaseResp, - removeServerGeneratedPropertiesFromCase, -} from '../../../common/lib/mock'; -import { deleteCases } from '../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('post_case', () => { - afterEach(async () => { - await deleteCases(es); - }); - - it('should post a case', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - const data = removeServerGeneratedPropertiesFromCase(postedCase); - expect(data).to.eql(postCaseResp(postedCase.id)); - }); - - it('unhappy path - 400s when bad query supplied', async () => { - await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ ...postCaseReq, badKey: true }) - .expect(400); - }); - - it('unhappy path - 400s when connector is not supplied', async () => { - const { connector, ...caseWithoutConnector } = postCaseReq; - - await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(caseWithoutConnector) - .expect(400); - }); - - it('unhappy path - 400s when connector has wrong type', async () => { - await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - connector: { id: 'wrong', name: 'wrong', type: '.not-exists', fields: null }, - }) - .expect(400); - }); - - it('unhappy path - 400s when connector has wrong fields', async () => { - await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - connector: { - id: 'wrong', - name: 'wrong', - type: '.jira', - fields: { unsupported: 'value' }, - }, - }) - .expect(400); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts deleted file mode 100644 index 47842eeca6f1c..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts +++ /dev/null @@ -1,381 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../common/ftr_provider_context'; -import { ObjectRemover as ActionsRemover } from '../../../../alerting_api_integration/common/lib'; - -import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../plugins/cases/common/constants'; -import { - postCaseReq, - defaultUser, - postCommentUserReq, - postCollectionReq, -} from '../../../common/lib/mock'; -import { - deleteCases, - deleteCasesUserActions, - deleteComments, - deleteConfiguration, - getConfiguration, - getServiceNowConnector, -} from '../../../common/lib/utils'; -import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; -import { CaseStatuses } from '../../../../../plugins/cases/common/api'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); - const es = getService('es'); - - describe('push_case', () => { - const actionsRemover = new ActionsRemover(supertest); - - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); - }); - - afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteConfiguration(es); - await deleteCasesUserActions(es); - await actionsRemover.removeAll(); - }); - - it('should push a case', async () => { - const { body: connector } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send({ - ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, - }) - .expect(200); - - actionsRemover.add('default', connector.id, 'action', 'actions'); - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send( - getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - }) - ) - .expect(200); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - connector: getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - fields: { - urgency: '2', - impact: '2', - severity: '2', - category: 'software', - subcategory: 'os', - }, - }).connector, - }) - .expect(200); - - const { body } = await supertest - .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) - .set('kbn-xsrf', 'true') - .send({}) - .expect(200); - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { pushed_at, external_url, ...rest } = body.external_service; - - expect(rest).to.eql({ - pushed_by: defaultUser, - connector_id: connector.id, - connector_name: connector.name, - external_id: '123', - external_title: 'INC01', - }); - - // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins - expect( - external_url.includes( - 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' - ) - ).to.equal(true); - }); - - it('pushes a comment appropriately', async () => { - const { body: connector } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send({ - ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, - }) - .expect(200); - - actionsRemover.add('default', connector.id, 'action', 'actions'); - - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send( - getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - }) - ) - .expect(200); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - connector: getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - fields: { - urgency: '2', - impact: '2', - severity: '2', - category: 'software', - subcategory: 'os', - }, - }).connector, - }) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) - .set('kbn-xsrf', 'true') - .send(postCommentUserReq) - .expect(200); - - const { body } = await supertest - .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) - .set('kbn-xsrf', 'true') - .send({}) - .expect(200); - - expect(body.comments[0].pushed_by).to.eql(defaultUser); - }); - - it('should pushes a case and closes when closure_type: close-by-pushing', async () => { - const { body: connector } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send({ - ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, - }) - .expect(200); - - actionsRemover.add('default', connector.id, 'action', 'actions'); - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send({ - ...getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - }), - closure_type: 'close-by-pushing', - }) - .expect(200); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - connector: getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - fields: { - urgency: '2', - impact: '2', - severity: '2', - category: 'software', - subcategory: 'os', - }, - }).connector, - }) - .expect(200); - - const { body } = await supertest - .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) - .set('kbn-xsrf', 'true') - .send({}) - .expect(200); - - expect(body.status).to.eql('closed'); - }); - - // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests - it.skip('should push a collection case but not close it when closure_type: close-by-pushing', async () => { - const { body: connector } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send({ - ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, - }) - .expect(200); - - actionsRemover.add('default', connector.id, 'action', 'actions'); - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send({ - ...getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - }), - closure_type: 'close-by-pushing', - }) - .expect(200); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCollectionReq, - connector: getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - fields: { - urgency: '2', - impact: '2', - severity: '2', - category: 'software', - subcategory: 'os', - }, - }).connector, - }) - .expect(200); - - const { body } = await supertest - .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) - .set('kbn-xsrf', 'true') - .send({}) - .expect(200); - - expect(body.status).to.eql(CaseStatuses.open); - }); - - it('unhappy path - 404s when case does not exist', async () => { - await supertest - .post(`${CASES_URL}/fake-id/connector/fake-connector/_push`) - .set('kbn-xsrf', 'true') - .send({}) - .expect(404); - }); - - it('unhappy path - 404s when connector does not exist', async () => { - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - connector: getConfiguration().connector, - }) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/connector/fake-connector/_push`) - .set('kbn-xsrf', 'true') - .send({}) - .expect(404); - }); - - it('unhappy path = 409s when case is closed', async () => { - const { body: connector } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send({ - ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, - }) - .expect(200); - - actionsRemover.add('default', connector.id, 'action', 'actions'); - - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send( - getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - }) - ) - .expect(200); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - connector: getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - fields: { - urgency: '2', - impact: '2', - severity: '2', - category: 'software', - subcategory: 'os', - }, - }).connector, - }) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - status: 'closed', - }, - ], - }) - .expect(200); - - await supertest - .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) - .set('kbn-xsrf', 'true') - .send({}) - .expect(409); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/reporters/get_reporters.ts b/x-pack/test/case_api_integration/basic/tests/cases/reporters/get_reporters.ts deleted file mode 100644 index c51bfda5bd8b0..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/reporters/get_reporters.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { CASES_URL, CASE_REPORTERS_URL } from '../../../../../../plugins/cases/common/constants'; -import { defaultUser, postCaseReq } from '../../../../common/lib/mock'; -import { deleteCases } from '../../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('get_reporters', () => { - afterEach(async () => { - await deleteCases(es); - }); - - it('should return reporters', async () => { - await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq).expect(200); - - const { body } = await supertest - .get(CASE_REPORTERS_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql([defaultUser]); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts b/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts deleted file mode 100644 index 1657293953246..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { CASES_URL, CASE_STATUS_URL } from '../../../../../../plugins/cases/common/constants'; -import { postCaseReq } from '../../../../common/lib/mock'; -import { deleteCases } from '../../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('get_status', () => { - afterEach(async () => { - await deleteCases(es); - }); - - it('should return case statuses', async () => { - await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq); - - const { body: inProgressCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send(postCaseReq) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: postedCase.id, - version: postedCase.version, - status: 'closed', - }, - ], - }) - .expect(200); - - await supertest - .patch(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - cases: [ - { - id: inProgressCase.id, - version: inProgressCase.version, - status: 'in-progress', - }, - ], - }) - .expect(200); - - const { body } = await supertest - .get(CASE_STATUS_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql({ - count_open_cases: 1, - count_closed_cases: 1, - count_in_progress_cases: 1, - }); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts b/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts deleted file mode 100644 index f5cbb7c7f0eb0..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; - -import { CASES_URL, CASE_TAGS_URL } from '../../../../../../plugins/cases/common/constants'; -import { postCaseReq } from '../../../../common/lib/mock'; -import { deleteCases } from '../../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('get_tags', () => { - afterEach(async () => { - await deleteCases(es); - }); - - it('should return case tags', async () => { - await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq); - await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ ...postCaseReq, tags: ['unique'] }) - .expect(200); - - const { body } = await supertest - .get(CASE_TAGS_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql(['defacement', 'unique']); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/configure/get_configure.ts b/x-pack/test/case_api_integration/basic/tests/configure/get_configure.ts deleted file mode 100644 index c892edff2d458..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/configure/get_configure.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../common/ftr_provider_context'; - -import { CASE_CONFIGURE_URL } from '../../../../../plugins/cases/common/constants'; -import { - getConfiguration, - removeServerGeneratedPropertiesFromConfigure, - getConfigurationOutput, - deleteConfiguration, -} from '../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('get_configure', () => { - afterEach(async () => { - await deleteConfiguration(es); - }); - - it('should return an empty find body correctly if no configuration is loaded', async () => { - const { body } = await supertest - .get(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql({}); - }); - - it('should return a configuration', async () => { - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send(getConfiguration()) - .expect(200); - - const { body } = await supertest - .get(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - const data = removeServerGeneratedPropertiesFromConfigure(body); - expect(data).to.eql(getConfigurationOutput()); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/configure/patch_configure.ts b/x-pack/test/case_api_integration/basic/tests/configure/patch_configure.ts deleted file mode 100644 index ea4982a8f04ad..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/configure/patch_configure.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../common/ftr_provider_context'; - -import { CASE_CONFIGURE_URL } from '../../../../../plugins/cases/common/constants'; -import { - getConfiguration, - removeServerGeneratedPropertiesFromConfigure, - getConfigurationOutput, - deleteConfiguration, -} from '../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('patch_configure', () => { - afterEach(async () => { - await deleteConfiguration(es); - }); - - it('should patch a configuration', async () => { - const res = await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send(getConfiguration()) - .expect(200); - - const { body } = await supertest - .patch(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send({ closure_type: 'close-by-pushing', version: res.body.version }) - .expect(200); - - const data = removeServerGeneratedPropertiesFromConfigure(body); - expect(data).to.eql({ ...getConfigurationOutput(true), closure_type: 'close-by-pushing' }); - }); - - it('should not patch a configuration with unsupported connector type', async () => { - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send(getConfiguration()) - .expect(200); - - await supertest - .patch(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - // @ts-ignore We need it to test unsupported types - .send(getConfiguration({ type: '.unsupported' })) - .expect(400); - }); - - it('should not patch a configuration with unsupported connector fields', async () => { - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send(getConfiguration()) - .expect(200); - - await supertest - .patch(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - // @ts-ignore We need it to test unsupported fields - .send(getConfiguration({ type: '.jira', fields: { unsupported: 'value' } })) - .expect(400); - }); - - it('should handle patch request when there is no configuration', async () => { - const { body } = await supertest - .patch(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send({ closure_type: 'close-by-pushing', version: 'no-version' }) - .expect(409); - - expect(body).to.eql({ - error: 'Conflict', - message: - 'You can not patch this configuration since you did not created first with a post.', - statusCode: 409, - }); - }); - - it('should handle patch request when versions are different', async () => { - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send(getConfiguration()) - .expect(200); - - const { body } = await supertest - .patch(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send({ closure_type: 'close-by-pushing', version: 'no-version' }) - .expect(409); - - expect(body).to.eql({ - error: 'Conflict', - message: - 'This configuration has been updated. Please refresh before saving additional updates.', - statusCode: 409, - }); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/configure/post_configure.ts b/x-pack/test/case_api_integration/basic/tests/configure/post_configure.ts deleted file mode 100644 index 7ab98a07cf046..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/configure/post_configure.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor 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 { FtrProviderContext } from '../../../common/ftr_provider_context'; - -import { CASE_CONFIGURE_URL } from '../../../../../plugins/cases/common/constants'; -import { - getConfiguration, - removeServerGeneratedPropertiesFromConfigure, - getConfigurationOutput, - deleteConfiguration, -} from '../../../common/lib/utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const es = getService('es'); - - describe('post_configure', () => { - afterEach(async () => { - await deleteConfiguration(es); - }); - - it('should create a configuration', async () => { - const { body } = await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send(getConfiguration()) - .expect(200); - - const data = removeServerGeneratedPropertiesFromConfigure(body); - expect(data).to.eql(getConfigurationOutput()); - }); - - it('should keep only the latest configuration', async () => { - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send(getConfiguration({ id: 'connector-2' })) - .expect(200); - - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send(getConfiguration()) - .expect(200); - - const { body } = await supertest - .get(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - const data = removeServerGeneratedPropertiesFromConfigure(body); - expect(data).to.eql(getConfigurationOutput()); - }); - - it('should not create a configuration with unsupported connector type', async () => { - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - // @ts-ignore We need it to test unsupported types - .send(getConfiguration({ type: '.unsupported' })) - .expect(400); - }); - - it('should not create a configuration with unsupported connector fields', async () => { - await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - // @ts-ignore We need it to test unsupported types - .send(getConfiguration({ type: '.jira', fields: { unsupported: 'value' } })) - .expect(400); - }); - }); -}; diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts deleted file mode 100644 index 24e6b12138895..0000000000000 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FtrProviderContext } from '../../common/ftr_provider_context'; - -// eslint-disable-next-line import/no-default-export -export default ({ loadTestFile }: FtrProviderContext): void => { - describe('case api basic', function () { - // Fastest ciGroup for the moment. - this.tags('ciGroup5'); - - loadTestFile(require.resolve('./cases/alerts/get_cases')); - loadTestFile(require.resolve('./cases/comments/delete_comment')); - loadTestFile(require.resolve('./cases/comments/find_comments')); - loadTestFile(require.resolve('./cases/comments/get_comment')); - loadTestFile(require.resolve('./cases/comments/get_all_comments')); - loadTestFile(require.resolve('./cases/comments/patch_comment')); - loadTestFile(require.resolve('./cases/comments/post_comment')); - loadTestFile(require.resolve('./cases/delete_cases')); - loadTestFile(require.resolve('./cases/find_cases')); - loadTestFile(require.resolve('./cases/get_case')); - loadTestFile(require.resolve('./cases/patch_cases')); - loadTestFile(require.resolve('./cases/post_case')); - loadTestFile(require.resolve('./cases/push_case')); - loadTestFile(require.resolve('./cases/reporters/get_reporters')); - loadTestFile(require.resolve('./cases/status/get_status')); - loadTestFile(require.resolve('./cases/tags/get_tags')); - loadTestFile(require.resolve('./cases/user_actions/get_all_user_actions')); - loadTestFile(require.resolve('./configure/get_configure')); - loadTestFile(require.resolve('./configure/get_connectors')); - loadTestFile(require.resolve('./configure/patch_configure')); - loadTestFile(require.resolve('./configure/post_configure')); - loadTestFile(require.resolve('./connectors/case')); - loadTestFile(require.resolve('./cases/sub_cases/patch_sub_cases')); - loadTestFile(require.resolve('./cases/sub_cases/delete_sub_cases')); - loadTestFile(require.resolve('./cases/sub_cases/get_sub_case')); - loadTestFile(require.resolve('./cases/sub_cases/find_sub_cases')); - - // Migrations - loadTestFile(require.resolve('./cases/migrations')); - loadTestFile(require.resolve('./configure/migrations')); - loadTestFile(require.resolve('./cases/user_actions/migrations')); - }); -}; diff --git a/x-pack/test/case_api_integration/common/config.ts b/x-pack/test/case_api_integration/common/config.ts index 8beabe450f66e..fef5478264fea 100644 --- a/x-pack/test/case_api_integration/common/config.ts +++ b/x-pack/test/case_api_integration/common/config.ts @@ -17,6 +17,7 @@ interface CreateTestConfigOptions { license: string; disabledPlugins?: string[]; ssl?: boolean; + testFiles?: string[]; } // test.not-enabled is specifically not enabled @@ -28,6 +29,7 @@ const enabledActionTypes = [ '.resilient', '.server-log', '.servicenow', + '.servicenow-sir', '.slack', '.webhook', '.case', @@ -39,7 +41,7 @@ const enabledActionTypes = [ ]; export function createTestConfig(name: string, options: CreateTestConfigOptions) { - const { license = 'trial', disabledPlugins = [], ssl = false } = options; + const { license = 'trial', disabledPlugins = [], ssl = false, testFiles = [] } = options; return async ({ readConfigFile }: FtrConfigProviderContext) => { const xPackApiIntegrationTestsConfig = await readConfigFile( @@ -54,7 +56,14 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) }, }; - const allFiles = fs.readdirSync( + // Find all folders in ./fixtures/plugins + const allFiles = fs.readdirSync(path.resolve(__dirname, 'fixtures', 'plugins')); + const plugins = allFiles.filter((file) => + fs.statSync(path.resolve(__dirname, 'fixtures', 'plugins', file)).isDirectory() + ); + + // This is needed so that we can correctly use the alerting test frameworks mock implementation for the connectors. + const alertingAllFiles = fs.readdirSync( path.resolve( __dirname, '..', @@ -65,7 +74,8 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) 'plugins' ) ); - const plugins = allFiles.filter((file) => + + const alertingPlugins = alertingAllFiles.filter((file) => fs .statSync( path.resolve( @@ -83,7 +93,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ); return { - testFiles: [require.resolve(`../${name}/tests/`)], + testFiles: testFiles ? testFiles : [require.resolve('../tests/common')], servers, services, junit: { @@ -109,7 +119,8 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, '--xpack.eventLog.logEntries=true', ...disabledPlugins.map((key) => `--xpack.${key}.enabled=false`), - ...plugins.map( + // Actions simulators plugin. Needed for testing push to external services. + ...alertingPlugins.map( (pluginDir) => `--plugin-path=${path.resolve( __dirname, @@ -122,6 +133,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) pluginDir )}` ), + ...plugins.map( + (pluginDir) => + `--plugin-path=${path.resolve(__dirname, 'fixtures', 'plugins', pluginDir)}` + ), `--server.xsrf.whitelist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`, ...(ssl ? [ diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/kibana.json b/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/kibana.json new file mode 100644 index 0000000000000..21dd9a58ffaad --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "casesClientUserFixture", + "version": "1.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack"], + "requiredPlugins": ["features", "cases"], + "optionalPlugins": ["security", "spaces"], + "server": true, + "ui": false +} diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/package.json b/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/package.json new file mode 100644 index 0000000000000..d396141fb0059 --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/package.json @@ -0,0 +1,14 @@ +{ + "name": "cases-client-user-fixture", + "version": "1.0.0", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "main": "target/test/plugin_api_integration/plugins/cases_client_user_fixture", + "scripts": { + "kbn": "node ../../../../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../../../../node_modules/.bin/tsc" + }, + "license": "Elastic License 2.0" +} diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/server/index.ts b/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/server/index.ts new file mode 100644 index 0000000000000..d39c2f2e714df --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/server/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext } from 'kibana/server'; +import { FixturePlugin } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => + new FixturePlugin(initializerContext); diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/server/plugin.ts b/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/server/plugin.ts new file mode 100644 index 0000000000000..d26da34579dff --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/cases_client_user/server/plugin.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { Plugin, CoreSetup, CoreStart, PluginInitializerContext, Logger } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; + +import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../../plugins/features/server'; +import { SpacesPluginStart } from '../../../../../../../plugins/spaces/server'; +import { SecurityPluginStart } from '../../../../../../../plugins/security/server'; +import { PluginStartContract as CasesPluginStart } from '../../../../../../../plugins/cases/server'; +import { CasesPatchRequest } from '../../../../../../../plugins/cases/common'; + +export interface FixtureSetupDeps { + features: FeaturesPluginSetup; +} + +export interface FixtureStartDeps { + security?: SecurityPluginStart; + spaces?: SpacesPluginStart; + cases?: CasesPluginStart; +} + +export class FixturePlugin implements Plugin { + private readonly log: Logger; + private casesPluginStart?: CasesPluginStart; + constructor(initContext: PluginInitializerContext) { + this.log = initContext.logger.get(); + } + + public setup(core: CoreSetup, deps: FixtureSetupDeps) { + const router = core.http.createRouter(); + /** + * This simply wraps the cases patch case api so that we can test updating the status of an alert using + * the cases client interface instead of going through the case plugin's RESTful interface + */ + router.patch( + { + path: '/api/cases_user/cases', + validate: { + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + try { + const client = await this.casesPluginStart?.getCasesClientWithRequest(request); + if (!client) { + throw new Error('Cases client was undefined'); + } + + return response.ok({ + body: await client.cases.update(request.body as CasesPatchRequest), + }); + } catch (error) { + this.log.error(`CasesClientUser failure: ${error}`); + throw error; + } + } + ); + } + public start(core: CoreStart, plugins: FixtureStartDeps) { + this.casesPluginStart = plugins.cases; + } + public stop() {} +} diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/observability/kibana.json b/x-pack/test/case_api_integration/common/fixtures/plugins/observability/kibana.json new file mode 100644 index 0000000000000..5115f4e3a0d3b --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/observability/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "observabilityFixtures", + "version": "1.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack"], + "requiredPlugins": ["features", "cases"], + "optionalPlugins": ["security", "spaces"], + "server": true, + "ui": false +} diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/observability/package.json b/x-pack/test/case_api_integration/common/fixtures/plugins/observability/package.json new file mode 100644 index 0000000000000..4d199ccd1badc --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/observability/package.json @@ -0,0 +1,14 @@ +{ + "name": "observability-fixtures", + "version": "1.0.0", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "main": "target/test/plugin_api_integration/plugins/observability_fixtures", + "scripts": { + "kbn": "node ../../../../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../../../../node_modules/.bin/tsc" + }, + "license": "Elastic License 2.0" +} \ No newline at end of file diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/observability/server/index.ts b/x-pack/test/case_api_integration/common/fixtures/plugins/observability/server/index.ts new file mode 100644 index 0000000000000..700aee6bfd49d --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/observability/server/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FixturePlugin } from './plugin'; + +export const plugin = () => new FixturePlugin(); diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/observability/server/plugin.ts b/x-pack/test/case_api_integration/common/fixtures/plugins/observability/server/plugin.ts new file mode 100644 index 0000000000000..f94358be2bc19 --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/observability/server/plugin.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 { Plugin, CoreSetup } from 'kibana/server'; + +import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../../plugins/features/server'; +import { SpacesPluginStart } from '../../../../../../../plugins/spaces/server'; +import { SecurityPluginStart } from '../../../../../../../plugins/security/server'; + +export interface FixtureSetupDeps { + features: FeaturesPluginSetup; +} + +export interface FixtureStartDeps { + security?: SecurityPluginStart; + spaces?: SpacesPluginStart; +} + +export class FixturePlugin implements Plugin { + public setup(core: CoreSetup, deps: FixtureSetupDeps) { + const { features } = deps; + features.registerKibanaFeature({ + id: 'observabilityFixture', + name: 'ObservabilityFixture', + app: ['kibana'], + category: { id: 'cases-fixtures', label: 'Cases Fixtures' }, + cases: ['observabilityFixture'], + privileges: { + all: { + app: ['kibana'], + cases: { + all: ['observabilityFixture'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + app: ['kibana'], + cases: { + read: ['observabilityFixture'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }); + } + public start() {} + public stop() {} +} diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/kibana.json b/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/kibana.json new file mode 100644 index 0000000000000..cdef22263b01e --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/kibana.json @@ -0,0 +1,10 @@ +{ + "id": "securitySolutionFixtures", + "version": "1.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack"], + "requiredPlugins": ["features", "cases"], + "optionalPlugins": ["security", "spaces"], + "server": true, + "ui": false +} diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/package.json b/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/package.json new file mode 100644 index 0000000000000..9a852dc1f0c49 --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/package.json @@ -0,0 +1,14 @@ +{ + "name": "security-solution-fixtures", + "version": "1.0.0", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "main": "target/test/plugin_api_integration/plugins/security_solution_fixtures", + "scripts": { + "kbn": "node ../../../../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../../../../node_modules/.bin/tsc" + }, + "license": "Elastic License 2.0" +} \ No newline at end of file diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/server/index.ts b/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/server/index.ts new file mode 100644 index 0000000000000..700aee6bfd49d --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/server/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FixturePlugin } from './plugin'; + +export const plugin = () => new FixturePlugin(); diff --git a/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/server/plugin.ts b/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/server/plugin.ts new file mode 100644 index 0000000000000..bd3569ef52816 --- /dev/null +++ b/x-pack/test/case_api_integration/common/fixtures/plugins/security_solution/server/plugin.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 { Plugin, CoreSetup } from 'kibana/server'; + +import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../../plugins/features/server'; +import { SpacesPluginStart } from '../../../../../../../plugins/spaces/server'; +import { SecurityPluginStart } from '../../../../../../../plugins/security/server'; + +export interface FixtureSetupDeps { + features: FeaturesPluginSetup; +} + +export interface FixtureStartDeps { + security?: SecurityPluginStart; + spaces?: SpacesPluginStart; +} + +export class FixturePlugin implements Plugin { + public setup(core: CoreSetup, deps: FixtureSetupDeps) { + const { features } = deps; + features.registerKibanaFeature({ + id: 'securitySolutionFixture', + name: 'SecuritySolutionFixture', + app: ['kibana'], + category: { id: 'cases-fixtures', label: 'Cases Fixtures' }, + cases: ['securitySolutionFixture'], + privileges: { + all: { + app: ['kibana'], + cases: { + all: ['securitySolutionFixture'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + app: ['kibana'], + cases: { + read: ['securitySolutionFixture'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }); + + features.registerKibanaFeature({ + id: 'testDisabledFixtureID', + name: 'TestDisabledFixture', + app: ['kibana'], + category: { id: 'cases-fixtures', label: 'Cases Fixtures' }, + // testDisabledFixture is disabled in space1 + cases: ['testDisabledFixture'], + privileges: { + all: { + app: ['kibana'], + cases: { + all: ['testDisabledFixture'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + app: ['kibana'], + cases: { + read: ['testDisabledFixture'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }); + } + public start() {} + public stop() {} +} diff --git a/x-pack/test/case_api_integration/common/lib/authentication/index.ts b/x-pack/test/case_api_integration/common/lib/authentication/index.ts new file mode 100644 index 0000000000000..86016b273ea44 --- /dev/null +++ b/x-pack/test/case_api_integration/common/lib/authentication/index.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext as CommonFtrProviderContext } from '../../../common/ftr_provider_context'; +import { Role, User, UserInfo } from './types'; +import { users } from './users'; +import { roles } from './roles'; +import { spaces } from './spaces'; + +export const getUserInfo = (user: User): UserInfo => ({ + username: user.username, + full_name: user.username.replace('_', ' '), + email: `${user.username}@elastic.co`, +}); + +export const createSpaces = async (getService: CommonFtrProviderContext['getService']) => { + const spacesService = getService('spaces'); + for (const space of spaces) { + await spacesService.create(space); + } +}; + +/** + * Creates the users and roles for use in the tests. Defaults to specific users and roles used by the security_and_spaces + * scenarios but can be passed specific ones as well. + */ +export const createUsersAndRoles = async ( + getService: CommonFtrProviderContext['getService'], + usersToCreate: User[] = users, + rolesToCreate: Role[] = roles +) => { + const security = getService('security'); + + const createRole = async ({ name, privileges }: Role) => { + return await security.role.create(name, privileges); + }; + + const createUser = async (user: User) => { + const userInfo = getUserInfo(user); + + return await security.user.create(user.username, { + password: user.password, + roles: user.roles, + full_name: userInfo.full_name, + email: userInfo.email, + }); + }; + + for (const role of rolesToCreate) { + await createRole(role); + } + + for (const user of usersToCreate) { + await createUser(user); + } +}; + +export const deleteSpaces = async (getService: CommonFtrProviderContext['getService']) => { + const spacesService = getService('spaces'); + for (const space of spaces) { + try { + await spacesService.delete(space.id); + } catch (error) { + // ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users + } + } +}; + +export const deleteUsersAndRoles = async ( + getService: CommonFtrProviderContext['getService'], + usersToDelete: User[] = users, + rolesToDelete: Role[] = roles +) => { + const security = getService('security'); + + for (const user of usersToDelete) { + try { + await security.user.delete(user.username); + } catch (error) { + // ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users + } + } + + for (const role of rolesToDelete) { + try { + await security.role.delete(role.name); + } catch (error) { + // ignore errors because if a migration is run it will delete the .kibana index which remove the spaces and users + } + } +}; + +export const createSpacesAndUsers = async (getService: CommonFtrProviderContext['getService']) => { + await createSpaces(getService); + await createUsersAndRoles(getService); +}; + +export const deleteSpacesAndUsers = async (getService: CommonFtrProviderContext['getService']) => { + await deleteSpaces(getService); + await deleteUsersAndRoles(getService); +}; diff --git a/x-pack/test/case_api_integration/common/lib/authentication/roles.ts b/x-pack/test/case_api_integration/common/lib/authentication/roles.ts new file mode 100644 index 0000000000000..9ded86ef6524f --- /dev/null +++ b/x-pack/test/case_api_integration/common/lib/authentication/roles.ts @@ -0,0 +1,292 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { Role } from './types'; + +export const noKibanaPrivileges: Role = { + name: 'no_kibana_privileges', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + }, +}; + +export const globalRead: Role = { + name: 'global_read', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + securitySolutionFixture: ['read'], + observabilityFixture: ['read'], + actions: ['read'], + actionsSimulators: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const testDisabledPluginAll: Role = { + name: 'test_disabled_plugin_all', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + testDisabledFixtureID: ['all'], + securitySolutionFixture: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyAll: Role = { + name: 'sec_only_all', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + securitySolutionFixture: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyRead: Role = { + name: 'sec_only_read', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + securitySolutionFixture: ['read'], + actions: ['read'], + actionsSimulators: ['read'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityOnlyAll: Role = { + name: 'obs_only_all', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + observabilityFixture: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const observabilityOnlyRead: Role = { + name: 'obs_only_read', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + observabilityFixture: ['read'], + actions: ['read'], + actionsSimulators: ['read'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const roles = [ + noKibanaPrivileges, + globalRead, + securitySolutionOnlyAll, + securitySolutionOnlyRead, + observabilityOnlyAll, + observabilityOnlyRead, + testDisabledPluginAll, +]; + +/** + * These roles have access to all spaces. + */ + +export const securitySolutionOnlyAllSpacesAll: Role = { + name: 'sec_only_all', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + securitySolutionFixture: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const securitySolutionOnlyReadSpacesAll: Role = { + name: 'sec_only_read', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + securitySolutionFixture: ['read'], + actions: ['read'], + actionsSimulators: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const observabilityOnlyAllSpacesAll: Role = { + name: 'obs_only_all', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + observabilityFixture: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + +export const observabilityOnlyReadSpacesAll: Role = { + name: 'obs_only_read', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + observabilityFixture: ['read'], + actions: ['read'], + actionsSimulators: ['read'], + }, + spaces: ['*'], + }, + ], + }, +}; + +/** + * These roles are specifically for the security_only tests where the spaces plugin is disabled. Most of the roles (except + * for noKibanaPrivileges) have spaces: ['*'] effectively giving it access to the default space since no other spaces + * will exist when the spaces plugin is disabled. + */ +export const rolesDefaultSpace = [ + noKibanaPrivileges, + globalRead, + securitySolutionOnlyAllSpacesAll, + securitySolutionOnlyReadSpacesAll, + observabilityOnlyAllSpacesAll, + observabilityOnlyReadSpacesAll, +]; diff --git a/x-pack/test/case_api_integration/common/lib/authentication/spaces.ts b/x-pack/test/case_api_integration/common/lib/authentication/spaces.ts new file mode 100644 index 0000000000000..32b67397306f7 --- /dev/null +++ b/x-pack/test/case_api_integration/common/lib/authentication/spaces.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Space } from './types'; + +const space1: Space = { + id: 'space1', + name: 'Space 1', + disabledFeatures: ['testDisabledFixtureID'], +}; + +const space2: Space = { + id: 'space2', + name: 'Space 2', + disabledFeatures: [], +}; + +export const spaces: Space[] = [space1, space2]; diff --git a/x-pack/test/case_api_integration/common/lib/authentication/types.ts b/x-pack/test/case_api_integration/common/lib/authentication/types.ts new file mode 100644 index 0000000000000..3bf3629441f93 --- /dev/null +++ b/x-pack/test/case_api_integration/common/lib/authentication/types.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 Space { + id: string; + namespace?: string; + name: string; + disabledFeatures: string[]; +} + +export interface User { + username: string; + password: string; + description?: string; + roles: string[]; +} + +export interface UserInfo { + username: string; + full_name: string; + email: string; +} + +interface FeaturesPrivileges { + [featureId: string]: string[]; +} + +interface ElasticsearchIndices { + names: string[]; + privileges: string[]; +} + +export interface ElasticSearchPrivilege { + cluster?: string[]; + indices?: ElasticsearchIndices[]; +} + +export interface KibanaPrivilege { + spaces: string[]; + base?: string[]; + feature?: FeaturesPrivileges; +} + +export interface Role { + name: string; + privileges: { + elasticsearch?: ElasticSearchPrivilege; + kibana?: KibanaPrivilege[]; + }; +} diff --git a/x-pack/test/case_api_integration/common/lib/authentication/users.ts b/x-pack/test/case_api_integration/common/lib/authentication/users.ts new file mode 100644 index 0000000000000..d10e932f92405 --- /dev/null +++ b/x-pack/test/case_api_integration/common/lib/authentication/users.ts @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + securitySolutionOnlyAll, + observabilityOnlyAll, + securitySolutionOnlyRead, + observabilityOnlyRead, + globalRead as globalReadRole, + noKibanaPrivileges as noKibanaPrivilegesRole, + securitySolutionOnlyAllSpacesAll, + securitySolutionOnlyReadSpacesAll, + observabilityOnlyAllSpacesAll, + observabilityOnlyReadSpacesAll, + testDisabledPluginAll, +} from './roles'; +import { User } from './types'; + +export const superUser: User = { + username: 'superuser', + password: 'superuser', + roles: ['superuser'], +}; + +export const testDisabled: User = { + username: 'test_disabled', + password: 'test_disabled', + roles: [testDisabledPluginAll.name], +}; + +export const secOnly: User = { + username: 'sec_only', + password: 'sec_only', + roles: [securitySolutionOnlyAll.name], +}; + +export const secOnlyRead: User = { + username: 'sec_only_read', + password: 'sec_only_read', + roles: [securitySolutionOnlyRead.name], +}; + +export const obsOnly: User = { + username: 'obs_only', + password: 'obs_only', + roles: [observabilityOnlyAll.name], +}; + +export const obsOnlyRead: User = { + username: 'obs_only_read', + password: 'obs_only_read', + roles: [observabilityOnlyRead.name], +}; + +export const obsSec: User = { + username: 'obs_sec', + password: 'obs_sec', + roles: [securitySolutionOnlyAll.name, observabilityOnlyAll.name], +}; + +export const obsSecRead: User = { + username: 'obs_sec_read', + password: 'obs_sec_read', + roles: [securitySolutionOnlyRead.name, observabilityOnlyRead.name], +}; + +export const globalRead: User = { + username: 'global_read', + password: 'global_read', + roles: [globalReadRole.name], +}; + +export const noKibanaPrivileges: User = { + username: 'no_kibana_privileges', + password: 'no_kibana_privileges', + roles: [noKibanaPrivilegesRole.name], +}; + +export const users = [ + superUser, + secOnly, + secOnlyRead, + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + globalRead, + noKibanaPrivileges, + testDisabled, +]; + +/** + * These users will have access to all spaces. + */ + +export const secOnlySpacesAll: User = { + username: 'sec_only', + password: 'sec_only', + roles: [securitySolutionOnlyAllSpacesAll.name], +}; + +export const secOnlyReadSpacesAll: User = { + username: 'sec_only_read', + password: 'sec_only_read', + roles: [securitySolutionOnlyReadSpacesAll.name], +}; + +export const obsOnlySpacesAll: User = { + username: 'obs_only', + password: 'obs_only', + roles: [observabilityOnlyAllSpacesAll.name], +}; + +export const obsOnlyReadSpacesAll: User = { + username: 'obs_only_read', + password: 'obs_only_read', + roles: [observabilityOnlyReadSpacesAll.name], +}; + +export const obsSecSpacesAll: User = { + username: 'obs_sec', + password: 'obs_sec', + roles: [securitySolutionOnlyAllSpacesAll.name, observabilityOnlyAllSpacesAll.name], +}; + +export const obsSecReadSpacesAll: User = { + username: 'obs_sec_read', + password: 'obs_sec_read', + roles: [securitySolutionOnlyReadSpacesAll.name, observabilityOnlyReadSpacesAll.name], +}; + +/** + * These users are for the security_only tests because most of them have access to the default space instead of 'space1' + */ +export const usersDefaultSpace = [ + superUser, + secOnlySpacesAll, + secOnlyReadSpacesAll, + obsOnlySpacesAll, + obsOnlyReadSpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + globalRead, + noKibanaPrivileges, +]; diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts index 53dd6440a47df..015661b0158a1 100644 --- a/x-pack/test/case_api_integration/common/lib/mock.ts +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -31,6 +31,11 @@ import { } from '../../../../plugins/cases/common/api'; export const defaultUser = { email: null, full_name: null, username: 'elastic' }; +/** + * A null filled user will occur when the security plugin is disabled + */ +export const nullUser = { email: null, full_name: null, username: null }; + export const postCaseReq: CasePostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', @@ -44,8 +49,17 @@ export const postCaseReq: CasePostRequest = { settings: { syncAlerts: true, }, + owner: 'securitySolutionFixture', }; +/** + * Return a request for creating a case. + */ +export const getPostCaseRequest = (req?: Partial): CasePostRequest => ({ + ...postCaseReq, + ...req, +}); + /** * The fields for creating a collection style case. */ @@ -65,6 +79,7 @@ export const userActionPostResp: CasesClientPostRequest = { export const postCommentUserReq: CommentRequestUserType = { comment: 'This is a cool comment', type: CommentType.user, + owner: 'securitySolutionFixture', }; export const postCommentAlertReq: CommentRequestAlertType = { @@ -72,6 +87,7 @@ export const postCommentAlertReq: CommentRequestAlertType = { index: 'test-index', rule: { id: 'test-rule-id', name: 'test-index-id' }, type: CommentType.alert, + owner: 'securitySolutionFixture', }; export const postCommentGenAlertReq: ContextTypeGeneratedAlertType = { @@ -80,14 +96,15 @@ export const postCommentGenAlertReq: ContextTypeGeneratedAlertType = { { _id: 'test-id2', _index: 'test-index', ruleId: 'rule-id', ruleName: 'rule name' }, ]), type: CommentType.generatedAlert, + owner: 'securitySolutionFixture', }; export const postCaseResp = ( - id: string, + id?: string | null, req: CasePostRequest = postCaseReq ): Partial => ({ ...req, - id, + ...(id != null ? { id } : {}), comments: [], totalAlerts: 0, totalComment: 0, @@ -156,60 +173,6 @@ export const subCaseResp = ({ updated_by: defaultUser, }); -interface FormattedCollectionResponse { - caseInfo: Partial; - subCases?: Array>; - comments?: Array>; -} - -export const formatCollectionResponse = (caseInfo: CaseResponse): FormattedCollectionResponse => { - const subCase = removeServerGeneratedPropertiesFromSubCase(caseInfo.subCases?.[0]); - return { - caseInfo: removeServerGeneratedPropertiesFromCaseCollection(caseInfo), - subCases: subCase ? [subCase] : undefined, - comments: removeServerGeneratedPropertiesFromComments( - caseInfo.subCases?.[0].comments ?? caseInfo.comments - ), - }; -}; - -export const removeServerGeneratedPropertiesFromSubCase = ( - subCase: Partial | undefined -): Partial | undefined => { - if (!subCase) { - return; - } - // eslint-disable-next-line @typescript-eslint/naming-convention - const { closed_at, created_at, updated_at, version, comments, ...rest } = subCase; - return rest; -}; - -export const removeServerGeneratedPropertiesFromCaseCollection = ( - config: Partial -): Partial => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { closed_at, created_at, updated_at, version, subCases, ...rest } = config; - return rest; -}; - -export const removeServerGeneratedPropertiesFromCase = ( - config: Partial -): Partial => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { closed_at, created_at, updated_at, version, ...rest } = config; - return rest; -}; - -export const removeServerGeneratedPropertiesFromComments = ( - comments: CommentResponse[] | undefined -): Array> | undefined => { - return comments?.map((comment) => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { created_at, updated_at, version, ...rest } = comment; - return rest; - }); -}; - const findCommon = { page: 1, per_page: 20, diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index f7ff49727df33..c66aeb67b3a5f 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -4,14 +4,24 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import expect from '@kbn/expect'; +import { omit } from 'lodash'; +import expect from '@kbn/expect'; import type { ApiResponse, estypes } from '@elastic/elasticsearch'; import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; import * as st from 'supertest'; import supertestAsPromised from 'supertest-as-promised'; -import { CASES_URL, SUB_CASES_PATCH_DEL_URL } from '../../../../plugins/cases/common/constants'; +import { ObjectRemover as ActionsRemover } from '../../../alerting_api_integration/common/lib'; +import { + CASES_URL, + CASE_CONFIGURE_CONNECTORS_URL, + CASE_CONFIGURE_URL, + CASE_REPORTERS_URL, + CASE_STATUS_URL, + CASE_TAGS_URL, + SUB_CASES_PATCH_DEL_URL, +} from '../../../../plugins/cases/common/constants'; import { CasesConfigureRequest, CasesConfigureResponse, @@ -23,11 +33,26 @@ import { CaseStatuses, SubCasesResponse, CasesResponse, + CasesFindResponse, + CommentRequest, + CaseUserActionResponse, + SubCaseResponse, + CommentResponse, + CasesPatchRequest, + AllCommentsResponse, + CommentPatchRequest, + CasesConfigurePatch, + CasesStatusResponse, + CasesConfigurationsResponse, + CaseUserActionsResponse, } from '../../../../plugins/cases/common/api'; -import { postCollectionReq, postCommentGenAlertReq } from './mock'; -import { getSubCasesUrl } from '../../../../plugins/cases/common/api/helpers'; +import { getPostCaseRequest, postCollectionReq, postCommentGenAlertReq } from './mock'; +import { getCaseUserActionUrl, getSubCasesUrl } from '../../../../plugins/cases/common/api/helpers'; import { ContextTypeGeneratedAlertType } from '../../../../plugins/cases/server/connectors'; import { SignalHit } from '../../../../plugins/security_solution/server/lib/detection_engine/signals/types'; +import { ActionResult, FindActionResult } from '../../../../plugins/actions/server/types'; +import { User } from './authentication/types'; +import { superUser } from './authentication/users'; function toArray(input: T | T[]): T[] { if (Array.isArray(input)) { @@ -144,11 +169,11 @@ export const createSubCase = async (args: { */ export const createCaseAction = async (supertest: st.SuperTest) => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', - actionTypeId: '.case', + connector_type_id: '.case', config: {}, }) .expect(200); @@ -162,7 +187,7 @@ export const deleteCaseAction = async ( supertest: st.SuperTest, id: string ) => { - await supertest.delete(`/api/actions/action/${id}`).set('kbn-xsrf', 'foo'); + await supertest.delete(`/api/actions/connector/${id}`).set('kbn-xsrf', 'foo'); }; /** @@ -231,7 +256,7 @@ export const createSubCaseComment = async ({ } const caseConnector = await supertest - .post(`/api/actions/action/${actionIDToUse}/_execute`) + .post(`/api/actions/connector/${actionIDToUse}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { @@ -248,12 +273,17 @@ export const createSubCaseComment = async ({ return { newSubCaseInfo: caseConnector.body.data, modifiedSubCases: closedSubCases }; }; -export const getConfiguration = ({ +type ConfigRequestParams = Partial & { + overrides?: Record; +}; + +export const getConfigurationRequest = ({ id = 'none', name = 'none', type = ConnectorTypes.none, fields = null, -}: Partial = {}): CasesConfigureRequest => { + overrides, +}: ConfigRequestParams = {}): CasesConfigureRequest => { return { connector: { id, @@ -262,22 +292,28 @@ export const getConfiguration = ({ fields, } as CaseConnector, closure_type: 'close-by-user', + owner: 'securitySolutionFixture', + ...overrides, }; }; -export const getConfigurationOutput = (update = false): Partial => { +export const getConfigurationOutput = ( + update = false, + overwrite = {} +): Partial => { return { - ...getConfiguration(), + ...getConfigurationRequest(), error: null, mappings: [], created_by: { email: null, full_name: null, username: 'elastic' }, updated_by: update ? { email: null, full_name: null, username: 'elastic' } : null, + ...overwrite, }; }; export const getServiceNowConnector = () => ({ name: 'ServiceNow Connector', - actionTypeId: '.servicenow', + connector_type_id: '.servicenow', secrets: { username: 'admin', password: 'password', @@ -289,7 +325,7 @@ export const getServiceNowConnector = () => ({ export const getJiraConnector = () => ({ name: 'Jira Connector', - actionTypeId: '.jira', + connector_type_id: '.jira', secrets: { email: 'elastic@elastic.co', apiToken: 'token', @@ -320,7 +356,7 @@ export const getMappings = () => [ export const getResilientConnector = () => ({ name: 'Resilient Connector', - actionTypeId: '.resilient', + connector_type_id: '.resilient', secrets: { apiKeyId: 'id', apiKeySecret: 'secret', @@ -331,21 +367,106 @@ export const getResilientConnector = () => ({ }, }); -export const removeServerGeneratedPropertiesFromConfigure = ( - config: Partial -): Partial => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { created_at, updated_at, version, ...rest } = config; - return rest; +export const getServiceNowSIRConnector = () => ({ + name: 'ServiceNow Connector', + connector_type_id: '.servicenow-sir', + secrets: { + username: 'admin', + password: 'password', + }, + config: { + apiUrl: 'http://some.non.existent.com', + }, +}); + +export const getWebhookConnector = () => ({ + name: 'A generic Webhook action', + connector_type_id: '.webhook', + secrets: { + user: 'user', + password: 'password', + }, + config: { + headers: { + 'Content-Type': 'text/plain', + }, + url: 'http://some.non.existent.com', + }, +}); + +interface CommonSavedObjectAttributes { + id?: string | null; + created_at?: string | null; + updated_at?: string | null; + version?: string | null; + [key: string]: unknown; +} + +const savedObjectCommonAttributes = ['created_at', 'updated_at', 'version', 'id']; + +const removeServerGeneratedPropertiesFromObject = ( + object: T, + keys: K[] +): Omit => { + return omit(object, keys); +}; +export const removeServerGeneratedPropertiesFromSavedObject = < + T extends CommonSavedObjectAttributes +>( + attributes: T, + keys: Array = [] +): Omit => { + return removeServerGeneratedPropertiesFromObject(attributes, [ + ...savedObjectCommonAttributes, + ...keys, + ]); +}; + +export const removeServerGeneratedPropertiesFromUserAction = ( + attributes: CaseUserActionResponse +) => { + const keysToRemove: Array = ['action_id', 'action_at']; + return removeServerGeneratedPropertiesFromObject< + CaseUserActionResponse, + typeof keysToRemove[number] + >(attributes, keysToRemove); +}; + +export const removeServerGeneratedPropertiesFromSubCase = ( + subCase: SubCaseResponse | undefined +) => { + if (!subCase) { + return; + } + + return removeServerGeneratedPropertiesFromSavedObject(subCase, [ + 'closed_at', + 'comments', + ]); +}; + +export const removeServerGeneratedPropertiesFromCase = ( + theCase: CaseResponse +): Partial => { + return removeServerGeneratedPropertiesFromSavedObject(theCase, ['closed_at']); +}; + +export const removeServerGeneratedPropertiesFromComments = ( + comments: CommentResponse[] | undefined +): Array> | undefined => { + return comments?.map((comment) => { + return removeServerGeneratedPropertiesFromSavedObject(comment, []); + }); }; export const deleteAllCaseItems = async (es: KibanaClient) => { await Promise.all([ - deleteCases(es), + deleteCasesByESQuery(es), deleteSubCases(es), deleteCasesUserActions(es), deleteComments(es), deleteConfiguration(es), + deleteMappings(es), ]); }; @@ -357,10 +478,11 @@ export const deleteCasesUserActions = async (es: KibanaClient): Promise => wait_for_completion: true, refresh: true, body: {}, + conflicts: 'proceed', }); }; -export const deleteCases = async (es: KibanaClient): Promise => { +export const deleteCasesByESQuery = async (es: KibanaClient): Promise => { await es.deleteByQuery({ index: '.kibana', // @ts-expect-error @elastic/elasticsearch DeleteByQueryRequest doesn't accept q parameter @@ -368,6 +490,7 @@ export const deleteCases = async (es: KibanaClient): Promise => { wait_for_completion: true, refresh: true, body: {}, + conflicts: 'proceed', }); }; @@ -383,6 +506,7 @@ export const deleteSubCases = async (es: KibanaClient): Promise => { wait_for_completion: true, refresh: true, body: {}, + conflicts: 'proceed', }); }; @@ -394,6 +518,7 @@ export const deleteComments = async (es: KibanaClient): Promise => { wait_for_completion: true, refresh: true, body: {}, + conflicts: 'proceed', }); }; @@ -405,5 +530,581 @@ export const deleteConfiguration = async (es: KibanaClient): Promise => { wait_for_completion: true, refresh: true, body: {}, + conflicts: 'proceed', }); }; + +export const deleteMappings = async (es: KibanaClient): Promise => { + await es.deleteByQuery({ + index: '.kibana', + // @ts-expect-error @elastic/elasticsearch DeleteByQueryRequest doesn't accept q parameter + q: 'type:cases-connector-mappings', + wait_for_completion: true, + refresh: true, + body: {}, + conflicts: 'proceed', + }); +}; + +export const superUserSpace1Auth = getAuthWithSuperUser(); + +/** + * Returns an auth object with the specified space and user set as super user. The result can be passed to other utility + * functions. + */ +export function getAuthWithSuperUser( + space: string | null = 'space1' +): { user: User; space: string | null } { + return { user: superUser, space }; +} + +/** + * Converts the space into the appropriate string for use by the actions remover utility object. + */ +export function getActionsSpace(space: string | null) { + return space ?? 'default'; +} + +export const getSpaceUrlPrefix = (spaceId: string | undefined | null) => { + return spaceId && spaceId !== 'default' ? `/s/${spaceId}` : ``; +}; + +interface OwnerEntity { + owner: string; +} + +export const ensureSavedObjectIsAuthorized = ( + entities: OwnerEntity[], + numberOfExpectedCases: number, + owners: string[] +) => { + expect(entities.length).to.eql(numberOfExpectedCases); + entities.forEach((entity) => expect(owners.includes(entity.owner)).to.be(true)); +}; + +export const createCaseWithConnector = async ({ + supertest, + configureReq = {}, + servicenowSimulatorURL, + actionsRemover, + auth = { user: superUser, space: null }, + createCaseReq = getPostCaseRequest(), +}: { + supertest: st.SuperTest; + servicenowSimulatorURL: string; + actionsRemover: ActionsRemover; + configureReq?: Record; + auth?: { user: User; space: string | null }; + createCaseReq?: CasePostRequest; +}): Promise<{ + postedCase: CaseResponse; + connector: CreateConnectorResponse; +}> => { + const connector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + auth, + }); + + actionsRemover.add(auth.space ?? 'default', connector.id, 'action', 'actions'); + await createConfiguration( + supertest, + { + ...getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + }), + ...configureReq, + }, + 200, + auth + ); + + const postedCase = await createCase( + supertest, + { + ...createCaseReq, + connector: { + id: connector.id, + name: connector.name, + type: connector.connector_type_id, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, + } as CaseConnector, + }, + 200, + auth + ); + + return { postedCase, connector }; +}; + +export const createCase = async ( + supertest: st.SuperTest, + params: CasePostRequest, + expectedHttpCode: number = 200, + auth: { user: User; space: string | null } = { user: superUser, space: null } +): Promise => { + const { body: theCase } = await supertest + .post(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send(params) + .expect(expectedHttpCode); + + return theCase; +}; + +/** + * Sends a delete request for the specified case IDs. + */ +export const deleteCases = async ({ + supertest, + caseIDs, + expectedHttpCode = 204, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + caseIDs: string[]; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}) => { + const { body } = await supertest + .delete(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}`) + .auth(auth.user.username, auth.user.password) + // we need to json stringify here because just passing in the array of case IDs will cause a 400 with Kibana + // not being able to parse the array correctly. The format ids=["1", "2"] seems to work, which stringify outputs. + .query({ ids: JSON.stringify(caseIDs) }) + .set('kbn-xsrf', 'true') + .send() + .expect(expectedHttpCode); + + return body; +}; + +export const createComment = async ({ + supertest, + caseId, + params, + auth = { user: superUser, space: null }, + expectedHttpCode = 200, +}: { + supertest: st.SuperTest; + caseId: string; + params: CommentRequest; + auth?: { user: User; space: string | null }; + expectedHttpCode?: number; +}): Promise => { + const { body: theCase } = await supertest + .post(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/${caseId}/comments`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send(params) + .expect(expectedHttpCode); + + return theCase; +}; + +export const updateCase = async ({ + supertest, + params, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + params: CasesPatchRequest; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: cases } = await supertest + .patch(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send(params) + .expect(expectedHttpCode); + + return cases; +}; + +export const getCaseUserActions = async ({ + supertest, + caseID, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + caseID: string; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: userActions } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${getCaseUserActionUrl(caseID)}`) + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); + return userActions; +}; + +export const deleteComment = async ({ + supertest, + caseId, + commentId, + expectedHttpCode = 204, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + caseId: string; + commentId: string; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise<{} | Error> => { + const { body: comment } = await supertest + .delete(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/${caseId}/comments/${commentId}`) + .set('kbn-xsrf', 'true') + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode) + .send(); + + return comment; +}; + +export const deleteAllComments = async ({ + supertest, + caseId, + expectedHttpCode = 204, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + caseId: string; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise<{} | Error> => { + const { body: comment } = await supertest + .delete(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/${caseId}/comments`) + .set('kbn-xsrf', 'true') + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode) + .send(); + + return comment; +}; + +export const getAllComments = async ({ + supertest, + caseId, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + caseId: string; + auth?: { user: User; space: string | null }; + expectedHttpCode?: number; +}): Promise => { + const { body: comments } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/${caseId}/comments`) + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); + + return comments; +}; + +export const getComment = async ({ + supertest, + caseId, + commentId, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + caseId: string; + commentId: string; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: comment } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/${caseId}/comments/${commentId}`) + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); + + return comment; +}; + +export const updateComment = async ({ + supertest, + caseId, + req, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + caseId: string; + req: CommentPatchRequest; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: res } = await supertest + .patch(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/${caseId}/comments`) + .set('kbn-xsrf', 'true') + .send(req) + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); + + return res; +}; + +export const getConfiguration = async ({ + supertest, + query = { owner: 'securitySolutionFixture' }, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + query?: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: configuration } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASE_CONFIGURE_URL}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .query(query) + .expect(expectedHttpCode); + + return configuration; +}; + +export const createConfiguration = async ( + supertest: st.SuperTest, + req: CasesConfigureRequest = getConfigurationRequest(), + expectedHttpCode: number = 200, + auth: { user: User; space: string | null } = { user: superUser, space: null } +): Promise => { + const { body: configuration } = await supertest + .post(`${getSpaceUrlPrefix(auth.space)}${CASE_CONFIGURE_URL}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send(req) + .expect(expectedHttpCode); + + return configuration; +}; + +export type CreateConnectorResponse = Omit & { + connector_type_id: string; +}; + +export const createConnector = async ({ + supertest, + req, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + req: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: connector } = await supertest + .post(`${getSpaceUrlPrefix(auth.space)}/api/actions/connector`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send(req) + .expect(expectedHttpCode); + + return connector; +}; + +export const getCaseConnectors = async ({ + supertest, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: connectors } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASE_CONFIGURE_CONNECTORS_URL}/_find`) + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); + + return connectors; +}; + +export const updateConfiguration = async ( + supertest: st.SuperTest, + id: string, + req: CasesConfigurePatch, + expectedHttpCode: number = 200, + auth: { user: User; space: string | null } = { user: superUser, space: null } +): Promise => { + const { body: configuration } = await supertest + .patch(`${getSpaceUrlPrefix(auth.space)}${CASE_CONFIGURE_URL}/${id}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send(req) + .expect(expectedHttpCode); + + return configuration; +}; + +export const getAllCasesStatuses = async ({ + supertest, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, + query = {}, +}: { + supertest: st.SuperTest; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; + query?: Record; +}): Promise => { + const { body: statuses } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASE_STATUS_URL}`) + .auth(auth.user.username, auth.user.password) + .query({ ...query }) + .expect(expectedHttpCode); + + return statuses; +}; + +export const getCase = async ({ + supertest, + caseId, + includeComments = false, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + caseId: string; + includeComments?: boolean; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: theCase } = await supertest + .get( + `${getSpaceUrlPrefix(auth?.space)}${CASES_URL}/${caseId}?includeComments=${includeComments}` + ) + .set('kbn-xsrf', 'true') + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); + + return theCase; +}; + +export const findCases = async ({ + supertest, + query = {}, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + query?: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: res } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/_find`) + .auth(auth.user.username, auth.user.password) + .query({ sortOrder: 'asc', ...query }) + .set('kbn-xsrf', 'true') + .send() + .expect(expectedHttpCode); + + return res; +}; + +export const getCaseIDsByAlert = async ({ + supertest, + alertID, + query = {}, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + alertID: string; + query?: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: res } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/alerts/${alertID}`) + .auth(auth.user.username, auth.user.password) + .query(query) + .expect(expectedHttpCode); + + return res; +}; + +export const getTags = async ({ + supertest, + query = {}, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + query?: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: res } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASE_TAGS_URL}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .query({ ...query }) + .expect(expectedHttpCode); + + return res; +}; + +export const getReporters = async ({ + supertest, + query = {}, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + query?: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: res } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASE_REPORTERS_URL}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .query({ ...query }) + .expect(expectedHttpCode); + + return res; +}; + +export const pushCase = async ({ + supertest, + caseId, + connectorId, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: st.SuperTest; + caseId: string; + connectorId: string; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: res } = await supertest + .post(`${getSpaceUrlPrefix(auth.space)}${CASES_URL}/${caseId}/connector/${connectorId}/_push`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send({}) + .expect(expectedHttpCode); + + return res; +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/config_basic.ts b/x-pack/test/case_api_integration/security_and_spaces/config_basic.ts new file mode 100644 index 0000000000000..98b7b1abe98e7 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/config_basic.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('security_and_spaces', { + license: 'basic', + ssl: true, + testFiles: [require.resolve('./tests/basic')], +}); diff --git a/x-pack/test/case_api_integration/basic/config.ts b/x-pack/test/case_api_integration/security_and_spaces/config_trial.ts similarity index 78% rename from x-pack/test/case_api_integration/basic/config.ts rename to x-pack/test/case_api_integration/security_and_spaces/config_trial.ts index ca4622c16ac92..b5328fd83c2cb 100644 --- a/x-pack/test/case_api_integration/basic/config.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/config_trial.ts @@ -8,8 +8,8 @@ import { createTestConfig } from '../common/config'; // eslint-disable-next-line import/no-default-export -export default createTestConfig('basic', { - disabledPlugins: [], +export default createTestConfig('security_and_spaces', { license: 'trial', ssl: true, + testFiles: [require.resolve('./tests/trial')], }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/basic/cases/push_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/basic/cases/push_case.ts new file mode 100644 index 0000000000000..5285b57f3be72 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/basic/cases/push_case.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { postCaseReq } from '../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + deleteConfiguration, + getConfigurationRequest, + getServiceNowConnector, + createConnector, + createConfiguration, + createCase, + pushCase, +} from '../../../../common/lib/utils'; +import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('push_case', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteConfiguration(es); + await deleteCasesUserActions(es); + }); + + it('should get 403 when trying to create a connector', async () => { + await createConnector({ supertest, req: getServiceNowConnector(), expectedHttpCode: 403 }); + }); + + it('should get 404 when trying to push to a case without a valid connector id', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + id: 'not-exist', + name: 'Not exist', + type: ConnectorTypes.serviceNowITSM, + }) + ); + + const postedCase = await createCase(supertest, { + ...postCaseReq, + connector: { + id: 'not-exist', + name: 'Not exist', + type: ConnectorTypes.serviceNowITSM, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, + }, + }); + + await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: 'not-exist', + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/basic/configure/create_connector.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/basic/configure/create_connector.ts new file mode 100644 index 0000000000000..fe8e311b5e4f6 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/basic/configure/create_connector.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createConnector, getServiceNowConnector } from '../../../../common/lib/utils'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function serviceNow({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('create service now action', () => { + it('should return 403 when creating a service now action', async () => { + await createConnector({ supertest, req: getServiceNowConnector(), expectedHttpCode: 403 }); + }); + }); +} diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/basic/index.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/basic/index.ts new file mode 100644 index 0000000000000..90fbb10637434 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/basic/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { createSpacesAndUsers, deleteSpacesAndUsers } from '../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('cases security and spaces enabled: basic', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + await createSpacesAndUsers(getService); + }); + + after(async () => { + await deleteSpacesAndUsers(getService); + }); + + // Basic + loadTestFile(require.resolve('./cases/push_case')); + + // Common + loadTestFile(require.resolve('../common')); + + // NOTE: These need to be at the end because they could delete the .kibana index and inadvertently remove the users and spaces + loadTestFile(require.resolve('../common/migrations')); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts new file mode 100644 index 0000000000000..e34f879e3aff8 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/alerts/get_cases.ts @@ -0,0 +1,312 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/mock'; +import { + createCase, + createComment, + getCaseIDsByAlert, + deleteAllCaseItems, +} from '../../../../common/lib/utils'; +import { CaseResponse } from '../../../../../../plugins/cases/common'; +import { + globalRead, + noKibanaPrivileges, + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + secOnly, + secOnlyRead, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_cases using alertID', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return all cases with the same alert ID attached to them', async () => { + const [case1, case2, case3] = await Promise.all([ + createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest()), + ]); + + await Promise.all([ + createComment({ supertest, caseId: case1.id, params: postCommentAlertReq }), + createComment({ supertest, caseId: case2.id, params: postCommentAlertReq }), + createComment({ supertest, caseId: case3.id, params: postCommentAlertReq }), + ]); + + const caseIDsWithAlert = await getCaseIDsByAlert({ supertest, alertID: 'test-id' }); + + expect(caseIDsWithAlert.length).to.eql(3); + expect(caseIDsWithAlert).to.contain(case1.id); + expect(caseIDsWithAlert).to.contain(case2.id); + expect(caseIDsWithAlert).to.contain(case3.id); + }); + + it('should return all cases with the same alert ID when more than 100 cases', async () => { + // if there are more than 100 responses, the implementation sets the aggregation size to the + // specific value + const numCases = 102; + const createCasePromises: Array> = []; + for (let i = 0; i < numCases; i++) { + createCasePromises.push(createCase(supertest, getPostCaseRequest())); + } + + const cases = await Promise.all(createCasePromises); + + const commentPromises: Array> = []; + for (const caseInfo of cases) { + commentPromises.push( + createComment({ supertest, caseId: caseInfo.id, params: postCommentAlertReq }) + ); + } + + await Promise.all(commentPromises); + + const caseIDsWithAlert = await getCaseIDsByAlert({ supertest, alertID: 'test-id' }); + + expect(caseIDsWithAlert.length).to.eql(numCases); + + for (const caseInfo of cases) { + expect(caseIDsWithAlert).to.contain(caseInfo.id); + } + }); + + it('should return no cases when the alert ID is not found', async () => { + const [case1, case2, case3] = await Promise.all([ + createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest()), + ]); + + await Promise.all([ + createComment({ supertest, caseId: case1.id, params: postCommentAlertReq }), + createComment({ supertest, caseId: case2.id, params: postCommentAlertReq }), + createComment({ supertest, caseId: case3.id, params: postCommentAlertReq }), + ]); + + const caseIDsWithAlert = await getCaseIDsByAlert({ supertest, alertID: 'test-id100' }); + + expect(caseIDsWithAlert.length).to.eql(0); + }); + + it('should return no cases when the owner filters them out', async () => { + const [case1, case2, case3] = await Promise.all([ + createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest()), + ]); + + await Promise.all([ + createComment({ supertest, caseId: case1.id, params: postCommentAlertReq }), + createComment({ supertest, caseId: case2.id, params: postCommentAlertReq }), + createComment({ supertest, caseId: case3.id, params: postCommentAlertReq }), + ]); + + const caseIDsWithAlert = await getCaseIDsByAlert({ + supertest, + alertID: 'test-id', + query: { owner: 'not-real' }, + }); + + expect(caseIDsWithAlert.length).to.eql(0); + }); + + it('should return a 302 when passing an empty alertID', async () => { + // kibana returns a 302 instead of a 400 when a url param is missing + await supertest.get(`${CASES_URL}/alerts/`).expect(302); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should return the correct case IDs', async () => { + const secOnlyAuth = { user: secOnly, space: 'space1' }; + const obsOnlyAuth = { user: obsOnly, space: 'space1' }; + + const [case1, case2, case3] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyAuth), + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyAuth), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyAuth + ), + ]); + + await Promise.all([ + createComment({ + supertest: supertestWithoutAuth, + caseId: case1.id, + params: postCommentAlertReq, + auth: secOnlyAuth, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: case2.id, + params: postCommentAlertReq, + auth: secOnlyAuth, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: case3.id, + params: { ...postCommentAlertReq, owner: 'observabilityFixture' }, + auth: obsOnlyAuth, + }), + ]); + + for (const scenario of [ + { + user: globalRead, + caseIDs: [case1.id, case2.id, case3.id], + }, + { + user: superUser, + caseIDs: [case1.id, case2.id, case3.id], + }, + { user: secOnlyRead, caseIDs: [case1.id, case2.id] }, + { user: obsOnlyRead, caseIDs: [case3.id] }, + { + user: obsSecRead, + caseIDs: [case1.id, case2.id, case3.id], + }, + ]) { + const res = await getCaseIDsByAlert({ + supertest: supertestWithoutAuth, + // cast because the official type is string | string[] but the ids will always be a single value in the tests + alertID: postCommentAlertReq.alertId as string, + auth: { + user: scenario.user, + space: 'space1', + }, + }); + expect(res.length).to.eql(scenario.caseIDs.length); + for (const caseID of scenario.caseIDs) { + expect(res).to.contain(caseID); + } + } + }); + + for (const scenario of [ + { user: noKibanaPrivileges, space: 'space1' }, + { user: secOnly, space: 'space2' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should not get cases`, async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest(), 200, { + user: superUser, + space: scenario.space, + }); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentAlertReq, + auth: { user: superUser, space: scenario.space }, + }); + + await getCaseIDsByAlert({ + supertest: supertestWithoutAuth, + alertID: postCommentAlertReq.alertId as string, + auth: { user: scenario.user, space: scenario.space }, + expectedHttpCode: 403, + }); + }); + } + + it('should respect the owner filter when have permissions', async () => { + const auth = { user: obsSec, space: 'space1' }; + const [case1, case2] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, auth), + createCase( + supertestWithoutAuth, + { ...getPostCaseRequest(), owner: 'observabilityFixture' }, + 200, + auth + ), + ]); + + await Promise.all([ + createComment({ + supertest: supertestWithoutAuth, + caseId: case1.id, + params: postCommentAlertReq, + auth, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: case2.id, + params: { ...postCommentAlertReq, owner: 'observabilityFixture' }, + auth, + }), + ]); + + const res = await getCaseIDsByAlert({ + supertest: supertestWithoutAuth, + alertID: postCommentAlertReq.alertId as string, + auth, + query: { owner: 'securitySolutionFixture' }, + }); + + expect(res).to.eql([case1.id]); + }); + + it('should return the correct case IDs when the owner query parameter contains unprivileged values', async () => { + const auth = { user: obsSec, space: 'space1' }; + const [case1, case2] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, auth), + createCase( + supertestWithoutAuth, + { ...getPostCaseRequest(), owner: 'observabilityFixture' }, + 200, + auth + ), + ]); + + await Promise.all([ + createComment({ + supertest: supertestWithoutAuth, + caseId: case1.id, + params: postCommentAlertReq, + auth, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: case2.id, + params: { ...postCommentAlertReq, owner: 'observabilityFixture' }, + auth, + }), + ]); + + const res = await getCaseIDsByAlert({ + supertest: supertestWithoutAuth, + alertID: postCommentAlertReq.alertId as string, + auth: { user: secOnly, space: 'space1' }, + // The secOnly user does not have permissions for observability cases, so it should only return the security solution one + query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, + }); + + expect(res).to.eql([case1.id]); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts new file mode 100644 index 0000000000000..964e9135aba7b --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts @@ -0,0 +1,318 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { defaultUser, getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + deleteCases, + createComment, + getComment, + removeServerGeneratedPropertiesFromUserAction, + getCase, + superUserSpace1Auth, + getCaseUserActions, +} from '../../../../common/lib/utils'; +import { getSubCaseDetailsUrl } from '../../../../../../plugins/cases/common/api/helpers'; +import { CaseResponse } from '../../../../../../plugins/cases/common/api'; +import { + secOnly, + secOnlyRead, + globalRead, + obsOnlyRead, + obsSecRead, + noKibanaPrivileges, + obsOnly, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const supertest = getService('supertest'); + const es = getService('es'); + + describe('delete_cases', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should delete a case', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + const body = await deleteCases({ supertest, caseIDs: [postedCase.id] }); + + expect(body).to.eql({}); + }); + + it(`should delete a case's comments when that case gets deleted`, async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + // ensure that we can get the comment before deleting the case + await getComment({ + supertest, + caseId: postedCase.id, + commentId: patchedCase.comments![0].id, + }); + + await deleteCases({ supertest, caseIDs: [postedCase.id] }); + + // make sure the comment is now gone + await getComment({ + supertest, + caseId: postedCase.id, + commentId: patchedCase.comments![0].id, + expectedHttpCode: 404, + }); + }); + + it('should create a user action when creating a case', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + await deleteCases({ supertest, caseIDs: [postedCase.id] }); + const userActions = await getCaseUserActions({ supertest, caseID: postedCase.id }); + const creationUserAction = removeServerGeneratedPropertiesFromUserAction(userActions[1]); + + expect(creationUserAction).to.eql({ + action_field: [ + 'description', + 'status', + 'tags', + 'title', + 'connector', + 'settings', + 'owner', + 'comment', + ], + action: 'delete', + action_by: defaultUser, + old_value: null, + new_value: null, + case_id: `${postedCase.id}`, + comment_id: null, + sub_case_id: '', + owner: 'securitySolutionFixture', + }); + }); + + it('unhappy path - 404s when case is not there', async () => { + await deleteCases({ supertest, caseIDs: ['fake-id'], expectedHttpCode: 404 }); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + describe.skip('sub cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should delete the sub cases when deleting a collection', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + expect(caseInfo.subCases![0].id).to.not.eql(undefined); + + const body = await deleteCases({ supertest, caseIDs: [caseInfo.id] }); + + expect(body).to.eql({}); + await supertest + .get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id)) + .send() + .expect(404); + }); + + it(`should delete a sub case's comments when that case gets deleted`, async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + expect(caseInfo.subCases![0].id).to.not.eql(undefined); + + // there should be two comments on the sub case now + const { body: patchedCaseWithSubCase }: { body: CaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments`) + .set('kbn-xsrf', 'true') + .query({ subCaseId: caseInfo.subCases![0].id }) + .send(postCommentUserReq) + .expect(200); + + const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${ + patchedCaseWithSubCase.comments![1].id + }`; + // make sure we can get the second comment + await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200); + + await deleteCases({ supertest, caseIDs: [caseInfo.id] }); + + await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(404); + }); + }); + + describe('rbac', () => { + it('User: security solution only - should delete a case', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnly, + space: 'space1', + } + ); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + expectedHttpCode: 204, + auth: { user: secOnly, space: 'space1' }, + }); + }); + + it('User: security solution only - should NOT delete a case of different owner', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnly, + space: 'space1', + } + ); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + expectedHttpCode: 403, + auth: { user: obsOnly, space: 'space1' }, + }); + }); + + it('should get an error if the user has not permissions to all requested cases', async () => { + const caseSec = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnly, + space: 'space1', + } + ); + + const caseObs = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: obsOnly, + space: 'space1', + } + ); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [caseSec.id, caseObs.id], + expectedHttpCode: 403, + auth: { user: obsOnly, space: 'space1' }, + }); + + // Cases should have not been deleted. + await getCase({ + supertest: supertestWithoutAuth, + caseId: caseSec.id, + expectedHttpCode: 200, + auth: superUserSpace1Auth, + }); + + await getCase({ + supertest: supertestWithoutAuth, + caseId: caseObs.id, + expectedHttpCode: 200, + auth: superUserSpace1Auth, + }); + }); + + for (const user of [globalRead, secOnlyRead, obsOnlyRead, obsSecRead, noKibanaPrivileges]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT delete a case`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + expectedHttpCode: 403, + auth: { user, space: 'space1' }, + }); + }); + } + + it('should NOT delete a case in a space with no permissions', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space2', + } + ); + + /** + * secOnly does not have access to space2 so it should 403 + */ + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + expectedHttpCode: 403, + auth: { user: secOnly, space: 'space2' }, + }); + }); + + it('should NOT delete a case created in space2 by making a request to space1', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space2', + } + ); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + expectedHttpCode: 404, + auth: { user: secOnly, space: 'space1' }, + }); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/find_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/find_cases.ts new file mode 100644 index 0000000000000..b7838dd9299bc --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/find_cases.ts @@ -0,0 +1,815 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { ApiResponse, estypes } from '@elastic/elasticsearch'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + CASES_URL, + SUB_CASES_PATCH_DEL_URL, +} from '../../../../../../plugins/cases/common/constants'; +import { + postCaseReq, + postCommentUserReq, + findCasesResp, + getPostCaseRequest, +} from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + createSubCase, + setStatus, + CreateSubCaseResp, + createCaseAction, + deleteCaseAction, + ensureSavedObjectIsAuthorized, + findCases, + createCase, + updateCase, + createComment, +} from '../../../../common/lib/utils'; +import { CaseResponse, CaseStatuses, CaseType } from '../../../../../../plugins/cases/common/api'; +import { + obsOnly, + secOnly, + obsOnlyRead, + secOnlyRead, + noKibanaPrivileges, + superUser, + globalRead, + obsSecRead, + obsSec, +} from '../../../../common/lib/authentication/users'; + +interface CaseAttributes { + cases: { + title: string; + }; +} + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('find_cases', () => { + describe('basic tests', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return empty response', async () => { + const cases = await findCases({ supertest }); + expect(cases).to.eql(findCasesResp); + }); + + it('should return cases', async () => { + const a = await createCase(supertest, postCaseReq); + const b = await createCase(supertest, postCaseReq); + const c = await createCase(supertest, postCaseReq); + + const cases = await findCases({ supertest }); + + expect(cases).to.eql({ + ...findCasesResp, + total: 3, + cases: [a, b, c], + count_open_cases: 3, + }); + }); + + it('filters by tags', async () => { + await createCase(supertest, postCaseReq); + const postedCase = await createCase(supertest, { ...postCaseReq, tags: ['unique'] }); + const cases = await findCases({ supertest, query: { tags: ['unique'] } }); + + expect(cases).to.eql({ + ...findCasesResp, + total: 1, + cases: [postedCase], + count_open_cases: 1, + }); + }); + + it('filters by status', async () => { + await createCase(supertest, postCaseReq); + const toCloseCase = await createCase(supertest, postCaseReq); + const patchedCase = await updateCase({ + supertest, + params: { + cases: [ + { + id: toCloseCase.id, + version: toCloseCase.version, + status: CaseStatuses.closed, + }, + ], + }, + }); + + const cases = await findCases({ supertest, query: { status: CaseStatuses.closed } }); + + expect(cases).to.eql({ + ...findCasesResp, + total: 1, + cases: [patchedCase[0]], + count_open_cases: 1, + count_closed_cases: 1, + count_in_progress_cases: 0, + }); + }); + + it('filters by reporters', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const cases = await findCases({ supertest, query: { reporters: 'elastic' } }); + + expect(cases).to.eql({ + ...findCasesResp, + total: 1, + cases: [postedCase], + count_open_cases: 1, + }); + }); + + it('correctly counts comments', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + // post 2 comments + await createComment({ supertest, caseId: postedCase.id, params: postCommentUserReq }); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + + const cases = await findCases({ supertest }); + expect(cases).to.eql({ + ...findCasesResp, + total: 1, + cases: [ + { + ...patchedCase, + comments: [], + totalComment: 2, + }, + ], + count_open_cases: 1, + }); + }); + + it('correctly counts open/closed/in-progress', async () => { + await createCase(supertest, postCaseReq); + const inProgressCase = await createCase(supertest, postCaseReq); + const postedCase = await createCase(supertest, postCaseReq); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: CaseStatuses.closed, + }, + ], + }, + }); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: inProgressCase.id, + version: inProgressCase.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + const cases = await findCases({ supertest }); + expect(cases.count_open_cases).to.eql(1); + expect(cases.count_closed_cases).to.eql(1); + expect(cases.count_in_progress_cases).to.eql(1); + }); + + it('unhappy path - 400s when bad query supplied', async () => { + await findCases({ supertest, query: { perPage: true }, expectedHttpCode: 400 }); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + describe.skip('stats with sub cases', () => { + let collection: CreateSubCaseResp; + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + beforeEach(async () => { + // create a collection with a sub case that is marked as open + collection = await createSubCase({ supertest, actionID }); + + const [, , { body: toCloseCase }] = await Promise.all([ + // set the sub case to in-progress + setStatus({ + supertest, + cases: [ + { + id: collection.newSubCaseInfo.subCases![0].id, + version: collection.newSubCaseInfo.subCases![0].version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'sub_case', + }), + // create two cases that are both open + supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), + supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq), + ]); + + // set the third case to closed + await setStatus({ + supertest, + cases: [ + { + id: toCloseCase.id, + version: toCloseCase.version, + status: CaseStatuses.closed, + }, + ], + type: 'case', + }); + }); + it('correctly counts stats without using a filter', async () => { + const cases = await findCases({ supertest }); + + expect(cases.total).to.eql(3); + expect(cases.count_closed_cases).to.eql(1); + expect(cases.count_open_cases).to.eql(1); + expect(cases.count_in_progress_cases).to.eql(1); + }); + + it('correctly counts stats with a filter for open cases', async () => { + const cases = await findCases({ supertest, query: { status: CaseStatuses.open } }); + + expect(cases.cases.length).to.eql(1); + + // since we're filtering on status and the collection only has an in-progress case, it should only return the + // individual case that has the open status and no collections + // ENABLE_CASE_CONNECTOR: this value is not correct because it includes a collection + // that does not have an open case. This is a known issue and will need to be resolved + // when this issue is addressed: https://github.com/elastic/kibana/issues/94115 + expect(cases.total).to.eql(2); + expect(cases.count_closed_cases).to.eql(1); + expect(cases.count_open_cases).to.eql(1); + expect(cases.count_in_progress_cases).to.eql(1); + }); + + it('correctly counts stats with a filter for individual cases', async () => { + const cases = await findCases({ supertest, query: { type: CaseType.individual } }); + + expect(cases.total).to.eql(2); + expect(cases.count_closed_cases).to.eql(1); + expect(cases.count_open_cases).to.eql(1); + expect(cases.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats with a filter for collection cases with multiple sub cases', async () => { + // this will force the first sub case attached to the collection to be closed + // so we'll have one closed sub case and one open sub case + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); + const cases = await findCases({ supertest, query: { type: CaseType.collection } }); + + expect(cases.total).to.eql(1); + expect(cases.cases[0].subCases?.length).to.eql(2); + expect(cases.count_closed_cases).to.eql(1); + expect(cases.count_open_cases).to.eql(1); + expect(cases.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats with a filter for collection and open cases with multiple sub cases', async () => { + // this will force the first sub case attached to the collection to be closed + // so we'll have one closed sub case and one open sub case + await createSubCase({ supertest, caseID: collection.newSubCaseInfo.id, actionID }); + const cases = await findCases({ + supertest, + query: { + type: CaseType.collection, + status: CaseStatuses.open, + }, + }); + + expect(cases.total).to.eql(1); + expect(cases.cases[0].subCases?.length).to.eql(1); + expect(cases.count_closed_cases).to.eql(1); + expect(cases.count_open_cases).to.eql(1); + expect(cases.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats including a collection without sub cases when not filtering on status', async () => { + // delete the sub case on the collection so that it doesn't have any sub cases + await supertest + .delete( + `${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCases![0].id}"]` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + const cases = await findCases({ supertest, query: { type: CaseType.collection } }); + + // it should include the collection without sub cases because we did not pass in a filter on status + expect(cases.total).to.eql(3); + expect(cases.count_closed_cases).to.eql(1); + expect(cases.count_open_cases).to.eql(1); + expect(cases.count_in_progress_cases).to.eql(0); + }); + + it('correctly counts stats including a collection without sub cases when filtering on tags', async () => { + // delete the sub case on the collection so that it doesn't have any sub cases + await supertest + .delete( + `${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCases![0].id}"]` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + const cases = await findCases({ supertest, query: { tags: ['defacement'] } }); + + // it should include the collection without sub cases because we did not pass in a filter on status + expect(cases.total).to.eql(3); + expect(cases.count_closed_cases).to.eql(1); + expect(cases.count_open_cases).to.eql(1); + expect(cases.count_in_progress_cases).to.eql(0); + }); + + it('does not return collections without sub cases matching the requested status', async () => { + const cases = await findCases({ supertest, query: { status: CaseStatuses.closed } }); + + expect(cases.cases.length).to.eql(1); + // it should not include the collection that has a sub case as in-progress + // ENABLE_CASE_CONNECTOR: this value is not correct because it includes collections. This short term + // fix for when sub cases are not enabled. When the feature is completed the _find API + // will need to be fixed as explained in this ticket: https://github.com/elastic/kibana/issues/94115 + expect(cases.total).to.eql(2); + expect(cases.count_closed_cases).to.eql(1); + expect(cases.count_open_cases).to.eql(1); + expect(cases.count_in_progress_cases).to.eql(1); + }); + + it('does not return empty collections when filtering on status', async () => { + // delete the sub case on the collection so that it doesn't have any sub cases + await supertest + .delete( + `${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCases![0].id}"]` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + const cases = await findCases({ supertest, query: { status: CaseStatuses.closed } }); + + expect(cases.cases.length).to.eql(1); + + // ENABLE_CASE_CONNECTOR: this value is not correct because it includes collections. This short term + // fix for when sub cases are not enabled. When the feature is completed the _find API + // will need to be fixed as explained in this ticket: https://github.com/elastic/kibana/issues/94115 + expect(cases.total).to.eql(2); + expect(cases.count_closed_cases).to.eql(1); + expect(cases.count_open_cases).to.eql(1); + expect(cases.count_in_progress_cases).to.eql(0); + }); + }); + }); + + describe('find_cases pagination', () => { + const numCases = 10; + before(async () => { + await createCasesWithTitleAsNumber(numCases); + }); + + after(async () => { + await deleteAllCaseItems(es); + }); + + const createCasesWithTitleAsNumber = async (total: number): Promise => { + const responsePromises = []; + for (let i = 0; i < total; i++) { + // this doesn't guarantee that the cases will be created in order that the for-loop executes, + // for example case with title '2', could be created before the case with title '1' since we're doing a promise all here + // A promise all is just much faster than doing it one by one which would have guaranteed that the cases are + // created in the order that the for-loop executes + responsePromises.push(createCase(supertest, { ...postCaseReq, title: `${i}` })); + } + const responses = await Promise.all(responsePromises); + return responses; + }; + + /** + * This is used to retrieve all the cases in the same sorted order that we're expecting them to come back via the + * _find API so that we have a more true comparison instead of using the _find API to get all the cases which + * could mangle the results if the implementation had a bug. + * + * Ideally we could enforce how the cases are created in reasonable time, waiting for each api call to finish takes + * around 30 seconds which seemed too slow + */ + const getAllCasesSortedByCreatedAtAsc = async () => { + const cases: ApiResponse> = await es.search({ + index: '.kibana', + body: { + size: 10000, + sort: [{ 'cases.created_at': { unmapped_type: 'date', order: 'asc' } }], + query: { + term: { type: 'cases' }, + }, + }, + }); + return cases.body.hits.hits.map((hit) => hit._source); + }; + + it('returns the correct total when perPage is less than the total', async () => { + const cases = await findCases({ + supertest, + query: { + page: 1, + perPage: 5, + }, + }); + + expect(cases.cases.length).to.eql(5); + expect(cases.total).to.eql(10); + expect(cases.page).to.eql(1); + expect(cases.per_page).to.eql(5); + expect(cases.count_open_cases).to.eql(10); + expect(cases.count_closed_cases).to.eql(0); + expect(cases.count_in_progress_cases).to.eql(0); + }); + + it('returns the correct total when perPage is greater than the total', async () => { + const cases = await findCases({ + supertest, + query: { + page: 1, + perPage: 11, + }, + }); + + expect(cases.total).to.eql(10); + expect(cases.page).to.eql(1); + expect(cases.per_page).to.eql(11); + expect(cases.cases.length).to.eql(10); + expect(cases.count_open_cases).to.eql(10); + expect(cases.count_closed_cases).to.eql(0); + expect(cases.count_in_progress_cases).to.eql(0); + }); + + it('returns the correct total when perPage is equal to the total', async () => { + const cases = await findCases({ + supertest, + query: { + page: 1, + perPage: 10, + }, + }); + + expect(cases.total).to.eql(10); + expect(cases.page).to.eql(1); + expect(cases.per_page).to.eql(10); + expect(cases.cases.length).to.eql(10); + expect(cases.count_open_cases).to.eql(10); + expect(cases.count_closed_cases).to.eql(0); + expect(cases.count_in_progress_cases).to.eql(0); + }); + + it('returns the second page of results', async () => { + const perPage = 5; + const cases = await findCases({ + supertest, + query: { + page: 2, + perPage, + }, + }); + + expect(cases.total).to.eql(10); + expect(cases.page).to.eql(2); + expect(cases.per_page).to.eql(5); + expect(cases.cases.length).to.eql(5); + expect(cases.count_open_cases).to.eql(10); + expect(cases.count_closed_cases).to.eql(0); + expect(cases.count_in_progress_cases).to.eql(0); + + const allCases = await getAllCasesSortedByCreatedAtAsc(); + + cases.cases.map((caseInfo, index) => { + // we started on the second page of 10 cases with a perPage of 5, so the first case should 0 + 5 (index + perPage) + expect(caseInfo.title).to.eql(allCases[index + perPage]?.cases.title); + }); + }); + + it('paginates with perPage of 2 through 10 total cases', async () => { + const total = 10; + const perPage = 2; + + // it's less than or equal here because the page starts at 1, so page 5 is a valid page number + // and should have case titles 9, and 10 + for (let currentPage = 1; currentPage <= total / perPage; currentPage++) { + const cases = await findCases({ + supertest, + query: { + page: currentPage, + perPage, + }, + }); + + expect(cases.total).to.eql(total); + expect(cases.page).to.eql(currentPage); + expect(cases.per_page).to.eql(perPage); + expect(cases.cases.length).to.eql(perPage); + expect(cases.count_open_cases).to.eql(total); + expect(cases.count_closed_cases).to.eql(0); + expect(cases.count_in_progress_cases).to.eql(0); + + const allCases = await getAllCasesSortedByCreatedAtAsc(); + + cases.cases.map((caseInfo, index) => { + // for page 1, the cases tiles should be 0,1,2 for page 2: 3,4,5 etc (assuming the titles were sorted + // correctly) + expect(caseInfo.title).to.eql( + allCases[index + perPage * (currentPage - 1)]?.cases.title + ); + }); + } + }); + + it('retrieves the last three cases', async () => { + const cases = await findCases({ + supertest, + query: { + // this should skip the first 7 cases and only return the last 3 + page: 2, + perPage: 7, + }, + }); + + expect(cases.total).to.eql(10); + expect(cases.page).to.eql(2); + expect(cases.per_page).to.eql(7); + expect(cases.cases.length).to.eql(3); + expect(cases.count_open_cases).to.eql(10); + expect(cases.count_closed_cases).to.eql(0); + expect(cases.count_in_progress_cases).to.eql(0); + }); + }); + + describe('rbac', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return the correct cases', async () => { + await Promise.all([ + // Create case owned by the security solution user + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnly, + space: 'space1', + } + ), + // Create case owned by the observability user + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: obsOnly, + space: 'space1', + } + ), + ]); + + for (const scenario of [ + { + user: globalRead, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + { + user: superUser, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + { user: secOnlyRead, numberOfExpectedCases: 1, owners: ['securitySolutionFixture'] }, + { user: obsOnlyRead, numberOfExpectedCases: 1, owners: ['observabilityFixture'] }, + { + user: obsSecRead, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + ]) { + const res = await findCases({ + supertest: supertestWithoutAuth, + auth: { + user: scenario.user, + space: 'space1', + }, + }); + + ensureSavedObjectIsAuthorized(res.cases, scenario.numberOfExpectedCases, scenario.owners); + } + }); + + for (const scenario of [ + { user: noKibanaPrivileges, space: 'space1' }, + { user: secOnly, space: 'space2' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should NOT read a case`, async () => { + // super user creates a case at the appropriate space + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: scenario.space, + } + ); + + // user should not be able to read cases at the appropriate space + await findCases({ + supertest: supertestWithoutAuth, + auth: { + user: scenario.user, + space: scenario.space, + }, + expectedHttpCode: 403, + }); + }); + } + + it('should return the correct cases when trying to exploit RBAC through the search query parameter', async () => { + await Promise.all([ + // super user creates a case with owner securitySolutionFixture + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ), + // super user creates a case with owner observabilityFixture + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ), + ]); + + const res = await findCases({ + supertest: supertestWithoutAuth, + query: { + search: 'securitySolutionFixture observabilityFixture', + searchFields: 'owner', + }, + auth: { + user: secOnly, + space: 'space1', + }, + }); + + ensureSavedObjectIsAuthorized(res.cases, 1, ['securitySolutionFixture']); + }); + + // This test is to prevent a future developer to add the filter attribute without taking into consideration + // the authorizationFilter produced by the cases authorization class + it('should NOT allow to pass a filter query parameter', async () => { + await supertest + .get( + `${CASES_URL}/_find?sortOrder=asc&filter=cases.attributes.owner:"observabilityFixture"` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + + // This test ensures that the user is not allowed to define the namespaces query param + // so she cannot search across spaces + it('should NOT allow to pass a namespaces query parameter', async () => { + await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&namespaces[0]=*`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + + await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&namespaces=*`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + + it('should NOT allow to pass a non supported query parameter', async () => { + await supertest + .get(`${CASES_URL}/_find?notExists=papa`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + + it('should respect the owner filter when having permissions', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: obsSec, + space: 'space1', + } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: obsSec, + space: 'space1', + } + ), + ]); + + const res = await findCases({ + supertest: supertestWithoutAuth, + query: { + owner: 'securitySolutionFixture', + searchFields: 'owner', + }, + auth: { + user: obsSec, + space: 'space1', + }, + }); + + ensureSavedObjectIsAuthorized(res.cases, 1, ['securitySolutionFixture']); + }); + + it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: obsSec, + space: 'space1', + } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: obsSec, + space: 'space1', + } + ), + ]); + + // User with permissions only to security solution request cases from observability + const res = await findCases({ + supertest: supertestWithoutAuth, + query: { + owner: ['securitySolutionFixture', 'observabilityFixture'], + }, + auth: { + user: secOnly, + space: 'space1', + }, + }); + + // Only security solution cases are being returned + ensureSavedObjectIsAuthorized(res.cases, 1, ['securitySolutionFixture']); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/get_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/get_case.ts new file mode 100644 index 0000000000000..222632b41c297 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/get_case.ts @@ -0,0 +1,207 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { AttributesTypeUser } from '../../../../../../plugins/cases/common/api'; +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { + defaultUser, + postCaseReq, + postCaseResp, + postCommentUserReq, + getPostCaseRequest, +} from '../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + createCase, + getCase, + createComment, + removeServerGeneratedPropertiesFromCase, + removeServerGeneratedPropertiesFromSavedObject, +} from '../../../../common/lib/utils'; +import { + secOnly, + obsOnly, + globalRead, + superUser, + secOnlyRead, + obsOnlyRead, + obsSecRead, + noKibanaPrivileges, + obsSec, +} from '../../../../common/lib/authentication/users'; +import { getUserInfo } from '../../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('get_case', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('should return a case with no comments', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + const theCase = await getCase({ supertest, caseId: postedCase.id, includeComments: true }); + + const data = removeServerGeneratedPropertiesFromCase(theCase); + expect(data).to.eql(postCaseResp()); + expect(data.comments?.length).to.eql(0); + }); + + it('should return a case with comments', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await createComment({ supertest, caseId: postedCase.id, params: postCommentUserReq }); + const theCase = await getCase({ supertest, caseId: postedCase.id, includeComments: true }); + + const comment = removeServerGeneratedPropertiesFromSavedObject( + theCase.comments![0] as AttributesTypeUser + ); + + expect(theCase.comments?.length).to.eql(1); + expect(comment).to.eql({ + type: postCommentUserReq.type, + comment: postCommentUserReq.comment, + associationType: 'case', + created_by: defaultUser, + pushed_at: null, + pushed_by: null, + updated_by: null, + owner: 'securitySolutionFixture', + }); + }); + + it('should return a 400 when passing the includeSubCaseComments', async () => { + const { body } = await supertest + .get(`${CASES_URL}/case-id?includeSubCaseComments=true`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + + expect(body.message).to.contain('disabled'); + }); + + it('unhappy path - 404s when case is not there', async () => { + await supertest.get(`${CASES_URL}/fake-id`).set('kbn-xsrf', 'true').send().expect(404); + }); + + describe('rbac', () => { + it('should get a case', async () => { + const newCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + for (const user of [globalRead, superUser, secOnly, secOnlyRead, obsSec, obsSecRead]) { + const theCase = await getCase({ + supertest: supertestWithoutAuth, + caseId: newCase.id, + auth: { user, space: 'space1' }, + }); + + expect(theCase.owner).to.eql('securitySolutionFixture'); + } + }); + + it('should get a case with comments', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnly, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + expectedHttpCode: 200, + auth: { + user: secOnly, + space: 'space1', + }, + }); + + const theCase = await getCase({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + includeComments: true, + auth: { user: secOnly, space: 'space1' }, + }); + + const comment = removeServerGeneratedPropertiesFromSavedObject( + theCase.comments![0] as AttributesTypeUser + ); + + expect(theCase.comments?.length).to.eql(1); + expect(comment).to.eql({ + type: postCommentUserReq.type, + comment: postCommentUserReq.comment, + associationType: 'case', + created_by: getUserInfo(secOnly), + pushed_at: null, + pushed_by: null, + updated_by: null, + owner: 'securitySolutionFixture', + }); + }); + + it('should not get a case when the user does not have access to owner', async () => { + const newCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + for (const user of [noKibanaPrivileges, obsOnly, obsOnlyRead]) { + await getCase({ + supertest: supertestWithoutAuth, + caseId: newCase.id, + expectedHttpCode: 403, + auth: { user, space: 'space1' }, + }); + } + }); + + it('should NOT get a case in a space with no permissions', async () => { + const newCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space2', + } + ); + + await getCase({ + supertest: supertestWithoutAuth, + caseId: newCase.id, + expectedHttpCode: 403, + auth: { user: secOnly, space: 'space2' }, + }); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts similarity index 95% rename from x-pack/test/case_api_integration/basic/tests/cases/migrations.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts index abbb749a2aaca..42fcace768b15 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts @@ -6,8 +6,8 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../common/ftr_provider_context'; -import { CASES_URL } from '../../../../../plugins/cases/common/constants'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts new file mode 100644 index 0000000000000..286e08716ebf1 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts @@ -0,0 +1,1240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../../plugins/security_solution/common/constants'; +import { + CasesResponse, + CaseStatuses, + CaseType, + CommentType, + ConnectorTypes, +} from '../../../../../../plugins/cases/common/api'; +import { + defaultUser, + getPostCaseRequest, + postCaseReq, + postCaseResp, + postCollectionReq, + postCommentAlertReq, + postCommentUserReq, +} from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + getSignalsWithES, + setStatus, + createCase, + createComment, + updateCase, + getCaseUserActions, + removeServerGeneratedPropertiesFromCase, + removeServerGeneratedPropertiesFromUserAction, + findCases, + superUserSpace1Auth, +} from '../../../../common/lib/utils'; +import { + createSignalsIndex, + deleteSignalsIndex, + deleteAllAlerts, + getRuleForSignalTesting, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, + getSignalsByIds, + createRule, + getQuerySignalIds, +} from '../../../../../detection_engine_api_integration/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnly, + obsOnlyRead, + obsSecRead, + secOnly, + secOnlyRead, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('patch_cases', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + describe('happy path', () => { + it('should patch a case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + }); + + const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); + expect(data).to.eql({ + ...postCaseResp(), + title: 'new title', + updated_by: defaultUser, + }); + }); + + it('should closes the case correctly', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: CaseStatuses.closed, + }, + ], + }, + }); + + const userActions = await getCaseUserActions({ supertest, caseID: postedCase.id }); + const statusUserAction = removeServerGeneratedPropertiesFromUserAction(userActions[1]); + const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); + + expect(data).to.eql({ + ...postCaseResp(), + status: CaseStatuses.closed, + closed_by: defaultUser, + updated_by: defaultUser, + }); + + expect(statusUserAction).to.eql({ + action_field: ['status'], + action: 'update', + action_by: defaultUser, + new_value: CaseStatuses.closed, + old_value: CaseStatuses.open, + case_id: `${postedCase.id}`, + comment_id: null, + sub_case_id: '', + owner: 'securitySolutionFixture', + }); + }); + + it('should change the status of case to in-progress correctly', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + const userActions = await getCaseUserActions({ supertest, caseID: postedCase.id }); + const statusUserAction = removeServerGeneratedPropertiesFromUserAction(userActions[1]); + const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); + + expect(data).to.eql({ + ...postCaseResp(), + status: CaseStatuses['in-progress'], + updated_by: defaultUser, + }); + + expect(statusUserAction).to.eql({ + action_field: ['status'], + action: 'update', + action_by: defaultUser, + new_value: CaseStatuses['in-progress'], + old_value: CaseStatuses.open, + case_id: `${postedCase.id}`, + comment_id: null, + sub_case_id: '', + owner: 'securitySolutionFixture', + }); + }); + + it('should patch a case with new connector', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + connector: { + id: 'jira', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: null, parent: null }, + }, + }, + ], + }, + }); + + const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); + expect(data).to.eql({ + ...postCaseResp(), + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { issueType: 'Task', priority: null, parent: null }, + }, + updated_by: defaultUser, + }); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + it.skip('should allow converting an individual case to a collection when it does not have alerts', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + await updateCase({ + supertest, + params: { + cases: [ + { + id: patchedCase.id, + version: patchedCase.version, + type: CaseType.collection, + }, + ], + }, + }); + }); + }); + + describe('unhappy path', () => { + it('400s when attempting to change the owner of a case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + owner: 'observabilityFixture', + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('404s when case is not there', async () => { + await updateCase({ + supertest, + params: { + cases: [ + { + id: 'not-real', + version: 'version', + status: CaseStatuses.closed, + }, + ], + }, + expectedHttpCode: 404, + }); + }); + + it('400s when id is missing', async () => { + await updateCase({ + supertest, + params: { + cases: [ + // @ts-expect-error + { + version: 'version', + status: CaseStatuses.closed, + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('406s when fields are identical', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: CaseStatuses.open, + }, + ], + }, + expectedHttpCode: 406, + }); + }); + + it('400s when version is missing', async () => { + await updateCase({ + supertest, + params: { + cases: [ + // @ts-expect-error + { + id: 'not-real', + status: CaseStatuses.closed, + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + it.skip('should 400 and not allow converting a collection back to an individual case', async () => { + const postedCase = await createCase(supertest, postCollectionReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + type: CaseType.individual, + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('406s when excess data sent', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + // @ts-expect-error + badKey: 'closed', + }, + ], + }, + expectedHttpCode: 406, + }); + }); + + it('400s when bad data sent', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + // @ts-expect-error + status: true, + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('400s when unsupported status sent', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + // @ts-expect-error + status: 'not-supported', + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('400s when bad connector type sent', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + // @ts-expect-error + connector: { id: 'none', name: 'none', type: '.not-exists', fields: null }, + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('400s when bad connector sent', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + connector: { + id: 'jira', + name: 'Jira', + // @ts-expect-error + type: ConnectorTypes.jira, + // @ts-expect-error + fields: { unsupported: 'value' }, + }, + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('409s when version does not match', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: 'version', + // @ts-expect-error + status: 'closed', + }, + ], + }, + expectedHttpCode: 409, + }); + }); + + it('should 400 when attempting to update an individual case to a collection when it has alerts attached to it', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertReq, + }); + await updateCase({ + supertest, + params: { + cases: [ + { + id: patchedCase.id, + version: patchedCase.version, + type: CaseType.collection, + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed delete these tests + it('should 400 when attempting to update the case type when the case connector feature is disabled', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + type: CaseType.collection, + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + it.skip("should 400 when attempting to update a collection case's status", async () => { + const postedCase = await createCase(supertest, postCollectionReq); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: CaseStatuses.closed, + }, + ], + }, + expectedHttpCode: 400, + }); + }); + }); + + describe('alerts', () => { + describe('esArchiver', () => { + const defaultSignalsIndex = '.siem-signals-default-000001'; + + beforeEach(async () => { + await esArchiver.load('cases/signals/default'); + }); + afterEach(async () => { + await esArchiver.unload('cases/signals/default'); + await deleteAllCaseItems(es); + }); + + it('should update the status of multiple alerts attached to multiple cases', async () => { + const signalID = '5f2b9ec41f8febb1c06b5d1045aeabb9874733b7617e88a370510f2fb3a41a5d'; + const signalID2 = '4d0f4b1533e46b66b43bdd0330d23f39f2cf42a7253153270e38d30cce9ff0c6'; + + // does NOT updates alert status when adding comments and syncAlerts=false + const individualCase1 = await createCase(supertest, { + ...postCaseReq, + settings: { + syncAlerts: false, + }, + }); + + const updatedInd1WithComment = await createComment({ + supertest, + caseId: individualCase1.id, + params: { + alertId: signalID, + index: defaultSignalsIndex, + rule: { id: 'test-rule-id', name: 'test-index-id' }, + type: CommentType.alert, + owner: 'securitySolutionFixture', + }, + }); + + const individualCase2 = await createCase(supertest, { + ...postCaseReq, + settings: { + syncAlerts: false, + }, + }); + + const updatedInd2WithComment = await createComment({ + supertest, + caseId: individualCase2.id, + params: { + alertId: signalID2, + index: defaultSignalsIndex, + rule: { id: 'test-rule-id', name: 'test-index-id' }, + type: CommentType.alert, + owner: 'securitySolutionFixture', + }, + }); + + await es.indices.refresh({ index: defaultSignalsIndex }); + + let signals = await getSignalsWithES({ + es, + indices: defaultSignalsIndex, + ids: [signalID, signalID2], + }); + + // There should be no change in their status since syncing is disabled + expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be( + CaseStatuses.open + ); + expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be( + CaseStatuses.open + ); + + // does NOT updates alert status when the status is updated and syncAlerts=false + const updatedIndWithStatus: CasesResponse = (await setStatus({ + supertest, + cases: [ + { + id: updatedInd1WithComment.id, + version: updatedInd1WithComment.version, + status: CaseStatuses.closed, + }, + { + id: updatedInd2WithComment.id, + version: updatedInd2WithComment.version, + status: CaseStatuses['in-progress'], + }, + ], + type: 'case', + })) as CasesResponse; + + await es.indices.refresh({ index: defaultSignalsIndex }); + + signals = await getSignalsWithES({ + es, + indices: defaultSignalsIndex, + ids: [signalID, signalID2], + }); + + // There should still be no change in their status since syncing is disabled + expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be( + CaseStatuses.open + ); + expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be( + CaseStatuses.open + ); + + // it updates alert status when syncAlerts is turned on + // turn on the sync settings + await updateCase({ + supertest, + params: { + cases: updatedIndWithStatus.map((caseInfo) => ({ + id: caseInfo.id, + version: caseInfo.version, + settings: { syncAlerts: true }, + })), + }, + }); + + await es.indices.refresh({ index: defaultSignalsIndex }); + + signals = await getSignalsWithES({ + es, + indices: defaultSignalsIndex, + ids: [signalID, signalID2], + }); + + // alerts should be updated now that the + expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be( + CaseStatuses.closed + ); + expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be( + CaseStatuses['in-progress'] + ); + }); + }); + + describe('esArchiver', () => { + const defaultSignalsIndex = '.siem-signals-default-000001'; + + beforeEach(async () => { + await esArchiver.load('cases/signals/duplicate_ids'); + }); + afterEach(async () => { + await esArchiver.unload('cases/signals/duplicate_ids'); + await deleteAllCaseItems(es); + }); + + it('should not update the status of duplicate alert ids in separate indices', async () => { + const getSignals = async () => { + return getSignalsWithES({ + es, + indices: [defaultSignalsIndex, signalsIndex2], + ids: [signalIDInFirstIndex, signalIDInSecondIndex], + }); + }; + + // this id exists only in .siem-signals-default-000001 + const signalIDInFirstIndex = + 'cae78067e65582a3b277c1ad46ba3cb29044242fe0d24bbf3fcde757fdd31d1c'; + // This id exists in both .siem-signals-default-000001 and .siem-signals-default-000002 + const signalIDInSecondIndex = 'duplicate-signal-id'; + const signalsIndex2 = '.siem-signals-default-000002'; + + const individualCase = await createCase(supertest, { + ...postCaseReq, + settings: { + syncAlerts: false, + }, + }); + + const updatedIndWithComment = await createComment({ + supertest, + caseId: individualCase.id, + params: { + alertId: signalIDInFirstIndex, + index: defaultSignalsIndex, + rule: { id: 'test-rule-id', name: 'test-index-id' }, + type: CommentType.alert, + owner: 'securitySolutionFixture', + }, + }); + + const updatedIndWithComment2 = await createComment({ + supertest, + caseId: updatedIndWithComment.id, + params: { + alertId: signalIDInSecondIndex, + index: signalsIndex2, + rule: { id: 'test-rule-id', name: 'test-index-id' }, + type: CommentType.alert, + owner: 'securitySolutionFixture', + }, + }); + + await es.indices.refresh({ index: defaultSignalsIndex }); + + let signals = await getSignals(); + // There should be no change in their status since syncing is disabled + expect( + signals.get(defaultSignalsIndex)?.get(signalIDInFirstIndex)?._source?.signal.status + ).to.be(CaseStatuses.open); + expect( + signals.get(signalsIndex2)?.get(signalIDInSecondIndex)?._source?.signal.status + ).to.be(CaseStatuses.open); + + const updatedIndWithStatus: CasesResponse = (await setStatus({ + supertest, + cases: [ + { + id: updatedIndWithComment2.id, + version: updatedIndWithComment2.version, + status: CaseStatuses.closed, + }, + ], + type: 'case', + })) as CasesResponse; + + await es.indices.refresh({ index: defaultSignalsIndex }); + + signals = await getSignals(); + + // There should still be no change in their status since syncing is disabled + expect( + signals.get(defaultSignalsIndex)?.get(signalIDInFirstIndex)?._source?.signal.status + ).to.be(CaseStatuses.open); + expect( + signals.get(signalsIndex2)?.get(signalIDInSecondIndex)?._source?.signal.status + ).to.be(CaseStatuses.open); + + // turn on the sync settings + await updateCase({ + supertest, + params: { + cases: [ + { + id: updatedIndWithStatus[0].id, + version: updatedIndWithStatus[0].version, + settings: { syncAlerts: true }, + }, + ], + }, + }); + await es.indices.refresh({ index: defaultSignalsIndex }); + + signals = await getSignals(); + + // alerts should be updated now that the + expect( + signals.get(defaultSignalsIndex)?.get(signalIDInFirstIndex)?._source?.signal.status + ).to.be(CaseStatuses.closed); + expect( + signals.get(signalsIndex2)?.get(signalIDInSecondIndex)?._source?.signal.status + ).to.be(CaseStatuses.closed); + + // the duplicate signal id in the other index should not be affect (so its status should be open) + expect( + signals.get(defaultSignalsIndex)?.get(signalIDInSecondIndex)?._source?.signal.status + ).to.be(CaseStatuses.open); + }); + }); + + describe('detections rule', () => { + beforeEach(async () => { + await esArchiver.load('auditbeat/hosts'); + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('auditbeat/hosts'); + }); + + it('updates alert status when the status is updated and syncAlerts=true', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + const postedCase = await createCase(supertest, postCaseReq); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + const caseUpdated = await createComment({ + supertest, + caseId: postedCase.id, + params: { + alertId: alert._id, + index: alert._index, + rule: { + id: 'id', + name: 'name', + }, + type: CommentType.alert, + owner: 'securitySolutionFixture', + }, + }); + + await es.indices.refresh({ index: alert._index }); + await updateCase({ + supertest, + params: { + cases: [ + { + id: caseUpdated.id, + version: caseUpdated.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + // force a refresh on the index that the signal is stored in so that we can search for it and get the correct + // status + await es.indices.refresh({ index: alert._index }); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('in-progress'); + }); + + it('does NOT updates alert status when the status is updated and syncAlerts=false', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + + const postedCase = await createCase(supertest, { + ...postCaseReq, + settings: { syncAlerts: false }, + }); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + const caseUpdated = await createComment({ + supertest, + caseId: postedCase.id, + params: { + alertId: alert._id, + index: alert._index, + type: CommentType.alert, + rule: { + id: 'id', + name: 'name', + }, + owner: 'securitySolutionFixture', + }, + }); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: caseUpdated.id, + version: caseUpdated.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); + }); + + it('it updates alert status when syncAlerts is turned on', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + + const postedCase = await createCase(supertest, { + ...postCaseReq, + settings: { syncAlerts: false }, + }); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + const caseUpdated = await createComment({ + supertest, + caseId: postedCase.id, + params: { + alertId: alert._id, + index: alert._index, + rule: { + id: 'id', + name: 'name', + }, + type: CommentType.alert, + owner: 'securitySolutionFixture', + }, + }); + + // Update the status of the case with sync alerts off + const caseStatusUpdated = await updateCase({ + supertest, + params: { + cases: [ + { + id: caseUpdated.id, + version: caseUpdated.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + // Turn sync alerts on + await updateCase({ + supertest, + params: { + cases: [ + { + id: caseStatusUpdated[0].id, + version: caseStatusUpdated[0].version, + settings: { syncAlerts: true }, + }, + ], + }, + }); + + // refresh the index because syncAlerts was set to true so the alert's status should have been updated + await es.indices.refresh({ index: alert._index }); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('in-progress'); + }); + + it('it does NOT updates alert status when syncAlerts is turned off', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + + const postedCase = await createCase(supertest, postCaseReq); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + const caseUpdated = await createComment({ + supertest, + caseId: postedCase.id, + params: { + alertId: alert._id, + index: alert._index, + type: CommentType.alert, + rule: { + id: 'id', + name: 'name', + }, + owner: 'securitySolutionFixture', + }, + }); + + // Turn sync alerts off + const caseSettingsUpdated = await updateCase({ + supertest, + params: { + cases: [ + { + id: caseUpdated.id, + version: caseUpdated.version, + settings: { syncAlerts: false }, + }, + ], + }, + }); + + // Update the status of the case with sync alerts off + await updateCase({ + supertest, + params: { + cases: [ + { + id: caseSettingsUpdated[0].id, + version: caseSettingsUpdated[0].version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); + }); + }); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should update a case when the user has the correct permissions', async () => { + const postedCase = await createCase(supertestWithoutAuth, postCaseReq, 200, { + user: secOnly, + space: 'space1', + }); + + const patchedCases = await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + auth: { user: secOnly, space: 'space1' }, + }); + + expect(patchedCases[0].owner).to.eql('securitySolutionFixture'); + }); + + it('should update multiple cases when the user has the correct permissions', async () => { + const [case1, case2, case3] = await Promise.all([ + createCase(supertestWithoutAuth, postCaseReq, 200, { + user: superUser, + space: 'space1', + }), + createCase(supertestWithoutAuth, postCaseReq, 200, { + user: superUser, + space: 'space1', + }), + createCase(supertestWithoutAuth, postCaseReq, 200, { + user: superUser, + space: 'space1', + }), + ]); + + const patchedCases = await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: case1.id, + version: case1.version, + title: 'new title', + }, + { + id: case2.id, + version: case2.version, + title: 'new title', + }, + { + id: case3.id, + version: case3.version, + title: 'new title', + }, + ], + }, + auth: { user: secOnly, space: 'space1' }, + }); + + expect(patchedCases[0].owner).to.eql('securitySolutionFixture'); + expect(patchedCases[1].owner).to.eql('securitySolutionFixture'); + expect(patchedCases[2].owner).to.eql('securitySolutionFixture'); + }); + + it('should not update a case when the user does not have the correct ownership', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { user: obsOnly, space: 'space1' } + ); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + auth: { user: secOnly, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + + it('should not update any cases when the user does not have the correct ownership', async () => { + const [case1, case2, case3] = await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ), + ]); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: case1.id, + version: case1.version, + title: 'new title', + }, + { + id: case2.id, + version: case2.version, + title: 'new title', + }, + { + id: case3.id, + version: case3.version, + title: 'new title', + }, + ], + }, + auth: { user: secOnly, space: 'space1' }, + expectedHttpCode: 403, + }); + + const resp = await findCases({ supertest, auth: superUserSpace1Auth }); + expect(resp.cases.length).to.eql(3); + // the update should have failed and none of the title should have been changed + expect(resp.cases[0].title).to.eql(postCaseReq.title); + expect(resp.cases[1].title).to.eql(postCaseReq.title); + expect(resp.cases[2].title).to.eql(postCaseReq.title); + }); + + for (const user of [globalRead, secOnlyRead, obsOnlyRead, obsSecRead, noKibanaPrivileges]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT update a case`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + auth: { user, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + } + + it('should NOT create a case in a space with no permissions', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space2', + } + ); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + auth: { user: secOnly, space: 'space2' }, + expectedHttpCode: 403, + }); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts new file mode 100644 index 0000000000000..e8337fa9db502 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/post_case.ts @@ -0,0 +1,314 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import expect from '@kbn/expect'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { + ConnectorTypes, + ConnectorJiraTypeFields, + CaseStatuses, + CaseUserActionResponse, + CaseType, +} from '../../../../../../plugins/cases/common/api'; +import { getPostCaseRequest, postCaseResp, defaultUser } from '../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + createCase, + removeServerGeneratedPropertiesFromCase, + removeServerGeneratedPropertiesFromUserAction, + getCaseUserActions, +} from '../../../../common/lib/utils'; +import { + secOnly, + secOnlyRead, + globalRead, + obsOnlyRead, + obsSecRead, + noKibanaPrivileges, + testDisabled, +} from '../../../../common/lib/authentication/users'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('post_case', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + describe('happy path', () => { + it('should post a case', async () => { + const postedCase = await createCase( + supertest, + getPostCaseRequest({ + connector: { + id: '123', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, + }) + ); + const data = removeServerGeneratedPropertiesFromCase(postedCase); + + expect(data).to.eql( + postCaseResp( + null, + getPostCaseRequest({ + connector: { + id: '123', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, + }) + ) + ); + }); + + it('should post a case: none connector', async () => { + const postedCase = await createCase( + supertest, + getPostCaseRequest({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }) + ); + const data = removeServerGeneratedPropertiesFromCase(postedCase); + + expect(data).to.eql( + postCaseResp( + null, + getPostCaseRequest({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }) + ) + ); + }); + + it('should create a user action when creating a case', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + const userActions = await getCaseUserActions({ supertest, caseID: postedCase.id }); + const creationUserAction = removeServerGeneratedPropertiesFromUserAction(userActions[0]); + + const { new_value, ...rest } = creationUserAction as CaseUserActionResponse; + const parsedNewValue = JSON.parse(new_value!); + + expect(rest).to.eql({ + action_field: [ + 'description', + 'status', + 'tags', + 'title', + 'connector', + 'settings', + 'owner', + ], + action: 'create', + action_by: defaultUser, + old_value: null, + case_id: `${postedCase.id}`, + comment_id: null, + sub_case_id: '', + owner: 'securitySolutionFixture', + }); + + expect(parsedNewValue).to.eql({ + type: postedCase.type, + description: postedCase.description, + title: postedCase.title, + tags: postedCase.tags, + connector: postedCase.connector, + settings: postedCase.settings, + owner: postedCase.owner, + }); + }); + + it('creates the case without connector in the configuration', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + const data = removeServerGeneratedPropertiesFromCase(postedCase); + + expect(data).to.eql(postCaseResp()); + }); + }); + + describe('unhappy path', () => { + it('should not allow creating a collection style case', async () => { + await createCase(supertest, getPostCaseRequest({ type: CaseType.collection }), 400); + }); + + it('400s when bad query supplied', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + // @ts-expect-error + .send({ ...getPostCaseRequest({ badKey: true }) }) + .expect(400); + }); + + it('400s when connector is not supplied', async () => { + const { connector, ...caseWithoutConnector } = getPostCaseRequest(); + + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(caseWithoutConnector) + .expect(400); + }); + + it('400s when connector has wrong type', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + ...getPostCaseRequest({ + // @ts-expect-error + connector: { id: 'wrong', name: 'wrong', type: '.not-exists', fields: null }, + }), + }) + .expect(400); + }); + + it('400s when connector has wrong fields', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + ...getPostCaseRequest({ + // @ts-expect-error + connector: { + id: 'wrong', + name: 'wrong', + type: ConnectorTypes.jira, + fields: { unsupported: 'value' }, + } as ConnectorJiraTypeFields, + }), + }) + .expect(400); + }); + + it('400s when missing title', async () => { + const { title, ...caseWithoutTitle } = getPostCaseRequest(); + + await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(caseWithoutTitle).expect(400); + }); + + it('400s when missing description', async () => { + const { description, ...caseWithoutDescription } = getPostCaseRequest(); + + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(caseWithoutDescription) + .expect(400); + }); + + it('400s when missing tags', async () => { + const { tags, ...caseWithoutTags } = getPostCaseRequest(); + + await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(caseWithoutTags).expect(400); + }); + + it('400s if you passing status for a new case', async () => { + const req = getPostCaseRequest(); + + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ ...req, status: CaseStatuses.open }) + .expect(400); + }); + }); + + describe('rbac', () => { + it('returns a 403 when attempting to create a case with an owner that was from a disabled feature in the space', async () => { + const theCase = ((await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'testDisabledFixture' }), + 403, + { + user: testDisabled, + space: 'space1', + } + )) as unknown) as { message: string }; + + expect(theCase.message).to.eql( + 'Unauthorized to create case with owners: "testDisabledFixture"' + ); + }); + + it('User: security solution only - should create a case', async () => { + const theCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnly, + space: 'space1', + } + ); + expect(theCase.owner).to.eql('securitySolutionFixture'); + }); + + it('User: security solution only - should NOT create a case of different owner', async () => { + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 403, + { + user: secOnly, + space: 'space1', + } + ); + }); + + for (const user of [globalRead, secOnlyRead, obsOnlyRead, obsSecRead, noKibanaPrivileges]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT create a case`, async () => { + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 403, + { + user, + space: 'space1', + } + ); + }); + } + + it('should NOT create a case in a space with no permissions', async () => { + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 403, + { + user: secOnly, + space: 'space2', + } + ); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts new file mode 100644 index 0000000000000..e34d9ccad39ac --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/reporters/get_reporters.ts @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { defaultUser, getPostCaseRequest } from '../../../../../common/lib/mock'; +import { createCase, deleteCasesByESQuery, getReporters } from '../../../../../common/lib/utils'; +import { + secOnly, + obsOnly, + globalRead, + superUser, + secOnlyRead, + obsOnlyRead, + obsSecRead, + noKibanaPrivileges, + obsSec, +} from '../../../../../common/lib/authentication/users'; +import { getUserInfo } from '../../../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('get_reporters', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('should return reporters', async () => { + await createCase(supertest, getPostCaseRequest()); + const reporters = await getReporters({ supertest: supertestWithoutAuth }); + + expect(reporters).to.eql([defaultUser]); + }); + + it('should return unique reporters', async () => { + await createCase(supertest, getPostCaseRequest()); + await createCase(supertest, getPostCaseRequest()); + const reporters = await getReporters({ supertest: supertestWithoutAuth }); + + expect(reporters).to.eql([defaultUser]); + }); + + describe('rbac', () => { + it('User: security solution only - should read the correct reporters', async () => { + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnly, + space: 'space1', + } + ); + + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: obsOnly, + space: 'space1', + } + ); + + for (const scenario of [ + { + user: globalRead, + expectedReporters: [getUserInfo(secOnly), getUserInfo(obsOnly)], + }, + { + user: superUser, + expectedReporters: [getUserInfo(secOnly), getUserInfo(obsOnly)], + }, + { user: secOnlyRead, expectedReporters: [getUserInfo(secOnly)] }, + { user: obsOnlyRead, expectedReporters: [getUserInfo(obsOnly)] }, + { + user: obsSecRead, + expectedReporters: [getUserInfo(secOnly), getUserInfo(obsOnly)], + }, + ]) { + const reporters = await getReporters({ + supertest: supertestWithoutAuth, + expectedHttpCode: 200, + auth: { + user: scenario.user, + space: 'space1', + }, + }); + + expect(reporters).to.eql(scenario.expectedReporters); + } + }); + + for (const scenario of [ + { user: noKibanaPrivileges, space: 'space1' }, + { user: secOnly, space: 'space2' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should NOT get all reporters`, async () => { + // super user creates a case at the appropriate space + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: scenario.space, + } + ); + + // user should not be able to get all reporters at the appropriate space + await getReporters({ + supertest: supertestWithoutAuth, + expectedHttpCode: 403, + auth: { user: scenario.user, space: scenario.space }, + }); + }); + } + + it('should respect the owner filter when having permissions', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnly, + space: 'space1', + } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: obsOnly, + space: 'space1', + } + ), + ]); + + const reporters = await getReporters({ + supertest: supertestWithoutAuth, + auth: { + user: obsSec, + space: 'space1', + }, + query: { owner: 'securitySolutionFixture' }, + }); + + expect(reporters).to.eql([getUserInfo(secOnly)]); + }); + + it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnly, + space: 'space1', + } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { + user: obsOnly, + space: 'space1', + } + ), + ]); + + // User with permissions only to security solution request reporters from observability + const reporters = await getReporters({ + supertest: supertestWithoutAuth, + auth: { + user: secOnly, + space: 'space1', + }, + query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, + }); + + // Only security solution reporters are being returned + expect(reporters).to.eql([getUserInfo(secOnly)]); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/status/get_status.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/status/get_status.ts new file mode 100644 index 0000000000000..02ace7077a20a --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/status/get_status.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { CaseStatuses } from '../../../../../../../plugins/cases/common/api'; +import { getPostCaseRequest, postCaseReq } from '../../../../../common/lib/mock'; +import { + createCase, + updateCase, + getAllCasesStatuses, + deleteAllCaseItems, + superUserSpace1Auth, +} from '../../../../../common/lib/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnlyRead, + obsSecRead, + secOnly, + secOnlyRead, + superUser, +} from '../../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_status', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return case statuses', async () => { + const [, inProgressCase, postedCase] = await Promise.all([ + createCase(supertest, postCaseReq), + createCase(supertest, postCaseReq), + createCase(supertest, postCaseReq), + ]); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: inProgressCase.id, + version: inProgressCase.version, + status: CaseStatuses['in-progress'], + }, + { + id: postedCase.id, + version: postedCase.version, + status: CaseStatuses.closed, + }, + ], + }, + }); + + const statuses = await getAllCasesStatuses({ supertest }); + + expect(statuses).to.eql({ + count_open_cases: 1, + count_closed_cases: 1, + count_in_progress_cases: 1, + }); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should return the correct status stats', async () => { + /** + * Owner: Sec + * open: 0, in-prog: 1, closed: 1 + * Owner: Obs + * open: 1, in-prog: 1 + */ + const [inProgressSec, closedSec, , inProgressObs] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { + user: superUser, + space: 'space1', + }), + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { + user: superUser, + space: 'space1', + }), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserSpace1Auth + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserSpace1Auth + ), + ]); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: inProgressSec.id, + version: inProgressSec.version, + status: CaseStatuses['in-progress'], + }, + { + id: closedSec.id, + version: closedSec.version, + status: CaseStatuses.closed, + }, + { + id: inProgressObs.id, + version: inProgressObs.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + auth: superUserSpace1Auth, + }); + + for (const scenario of [ + { user: globalRead, stats: { open: 1, inProgress: 2, closed: 1 } }, + { user: superUser, stats: { open: 1, inProgress: 2, closed: 1 } }, + { user: secOnlyRead, stats: { open: 0, inProgress: 1, closed: 1 } }, + { user: obsOnlyRead, stats: { open: 1, inProgress: 1, closed: 0 } }, + { user: obsSecRead, stats: { open: 1, inProgress: 2, closed: 1 } }, + { + user: obsSecRead, + stats: { open: 1, inProgress: 1, closed: 0 }, + owner: 'observabilityFixture', + }, + { + user: obsSecRead, + stats: { open: 1, inProgress: 2, closed: 1 }, + owner: ['observabilityFixture', 'securitySolutionFixture'], + }, + ]) { + const statuses = await getAllCasesStatuses({ + supertest: supertestWithoutAuth, + auth: { user: scenario.user, space: 'space1' }, + query: { + owner: scenario.owner, + }, + }); + + expect(statuses).to.eql({ + count_open_cases: scenario.stats.open, + count_closed_cases: scenario.stats.closed, + count_in_progress_cases: scenario.stats.inProgress, + }); + } + }); + + for (const scenario of [ + { user: noKibanaPrivileges, space: 'space1' }, + { user: secOnly, space: 'space2' }, + ]) { + it(`should return a 403 when retrieving the statuses when the user ${ + scenario.user.username + } with role(s) ${scenario.user.roles.join()} and space ${scenario.space}`, async () => { + await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { + user: superUser, + space: scenario.space, + }); + + await getAllCasesStatuses({ + supertest: supertestWithoutAuth, + auth: { user: scenario.user, space: scenario.space }, + expectedHttpCode: 403, + }); + }); + } + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/tags/get_tags.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/tags/get_tags.ts new file mode 100644 index 0000000000000..0c7237683666f --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/tags/get_tags.ts @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { deleteCasesByESQuery, createCase, getTags } from '../../../../../common/lib/utils'; +import { getPostCaseRequest } from '../../../../../common/lib/mock'; +import { + secOnly, + obsOnly, + globalRead, + superUser, + secOnlyRead, + obsOnlyRead, + obsSecRead, + noKibanaPrivileges, + obsSec, +} from '../../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('get_tags', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('should return case tags', async () => { + await createCase(supertest, getPostCaseRequest()); + await createCase(supertest, getPostCaseRequest({ tags: ['unique'] })); + + const tags = await getTags({ supertest }); + expect(tags).to.eql(['defacement', 'unique']); + }); + + it('should return unique tags', async () => { + await createCase(supertest, getPostCaseRequest()); + await createCase(supertest, getPostCaseRequest()); + + const tags = await getTags({ supertest }); + expect(tags).to.eql(['defacement']); + }); + + describe('rbac', () => { + it('should read the correct tags', async () => { + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', tags: ['sec'] }), + 200, + { + user: secOnly, + space: 'space1', + } + ); + + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture', tags: ['obs'] }), + 200, + { + user: obsOnly, + space: 'space1', + } + ); + + for (const scenario of [ + { + user: globalRead, + expectedTags: ['sec', 'obs'], + }, + { + user: superUser, + expectedTags: ['sec', 'obs'], + }, + { user: secOnlyRead, expectedTags: ['sec'] }, + { user: obsOnlyRead, expectedTags: ['obs'] }, + { + user: obsSecRead, + expectedTags: ['sec', 'obs'], + }, + ]) { + const tags = await getTags({ + supertest: supertestWithoutAuth, + expectedHttpCode: 200, + auth: { + user: scenario.user, + space: 'space1', + }, + }); + + expect(tags).to.eql(scenario.expectedTags); + } + }); + + for (const scenario of [ + { user: noKibanaPrivileges, space: 'space1' }, + { user: secOnly, space: 'space2' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should NOT get all tags`, async () => { + // super user creates a case at the appropriate space + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', tags: ['sec'] }), + 200, + { + user: superUser, + space: scenario.space, + } + ); + + // user should not be able to get all tags at the appropriate space + await getTags({ + supertest: supertestWithoutAuth, + expectedHttpCode: 403, + auth: { user: scenario.user, space: scenario.space }, + }); + }); + } + + it('should respect the owner filter when having permissions', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', tags: ['sec'] }), + 200, + { + user: obsSec, + space: 'space1', + } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture', tags: ['obs'] }), + 200, + { + user: obsSec, + space: 'space1', + } + ), + ]); + + const tags = await getTags({ + supertest: supertestWithoutAuth, + auth: { + user: obsSec, + space: 'space1', + }, + query: { owner: 'securitySolutionFixture' }, + }); + + expect(tags).to.eql(['sec']); + }); + + it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', tags: ['sec'] }), + 200, + { + user: obsSec, + space: 'space1', + } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture', tags: ['obs'] }), + 200, + { + user: obsSec, + space: 'space1', + } + ), + ]); + + // User with permissions only to security solution request tags from observability + const tags = await getTags({ + supertest: supertestWithoutAuth, + auth: { + user: secOnly, + space: 'space1', + }, + query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, + }); + + // Only security solution tags are being returned + expect(tags).to.eql(['sec']); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/client/update_alert_status.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/client/update_alert_status.ts new file mode 100644 index 0000000000000..44284c0aec639 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/client/update_alert_status.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { postCaseReq } from '../../../../common/lib/mock'; +import { + createCase, + createComment, + deleteAllCaseItems, + getSignalsWithES, +} from '../../../../common/lib/utils'; +import { CasesResponse, CaseStatuses, CommentType } from '../../../../../../plugins/cases/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + describe('update_alert_status', () => { + const defaultSignalsIndex = '.siem-signals-default-000001'; + + beforeEach(async () => { + await esArchiver.load('cases/signals/default'); + }); + afterEach(async () => { + await esArchiver.unload('cases/signals/default'); + await deleteAllCaseItems(es); + }); + + it('should update the status of multiple alerts attached to multiple cases using the cases client', async () => { + const signalID = '5f2b9ec41f8febb1c06b5d1045aeabb9874733b7617e88a370510f2fb3a41a5d'; + const signalID2 = '4d0f4b1533e46b66b43bdd0330d23f39f2cf42a7253153270e38d30cce9ff0c6'; + + // does NOT updates alert status when adding comments and syncAlerts=false + const individualCase1 = await createCase(supertest, { + ...postCaseReq, + settings: { + syncAlerts: false, + }, + }); + + const updatedInd1WithComment = await createComment({ + supertest, + caseId: individualCase1.id, + params: { + alertId: signalID, + index: defaultSignalsIndex, + rule: { id: 'test-rule-id', name: 'test-index-id' }, + type: CommentType.alert, + owner: 'securitySolutionFixture', + }, + }); + + const individualCase2 = await createCase(supertest, { + ...postCaseReq, + settings: { + syncAlerts: false, + }, + }); + + const updatedInd2WithComment = await createComment({ + supertest, + caseId: individualCase2.id, + params: { + alertId: signalID2, + index: defaultSignalsIndex, + rule: { id: 'test-rule-id', name: 'test-index-id' }, + type: CommentType.alert, + owner: 'securitySolutionFixture', + }, + }); + + await es.indices.refresh({ index: defaultSignalsIndex }); + + let signals = await getSignalsWithES({ + es, + indices: defaultSignalsIndex, + ids: [signalID, signalID2], + }); + + // There should be no change in their status since syncing is disabled + expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be( + CaseStatuses.open + ); + expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be( + CaseStatuses.open + ); + + // does NOT updates alert status when the status is updated and syncAlerts=false + // this performs the cases update through the test plugin that leverages the cases client instead + // of going through RESTful API of the cases plugin + const { body: updatedIndWithStatus }: { body: CasesResponse } = await supertest + .patch('/api/cases_user/cases') + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: updatedInd1WithComment.id, + version: updatedInd1WithComment.version, + status: CaseStatuses.closed, + }, + { + id: updatedInd2WithComment.id, + version: updatedInd2WithComment.version, + status: CaseStatuses['in-progress'], + }, + ], + }) + .expect(200); + + await es.indices.refresh({ index: defaultSignalsIndex }); + + signals = await getSignalsWithES({ + es, + indices: defaultSignalsIndex, + ids: [signalID, signalID2], + }); + + // There should still be no change in their status since syncing is disabled + expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be( + CaseStatuses.open + ); + expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be( + CaseStatuses.open + ); + + // it updates alert status when syncAlerts is turned on + // turn on the sync settings + // this performs the cases update through the test plugin that leverages the cases client instead + // of going through RESTful API of the cases plugin + await supertest + .patch('/api/cases_user/cases') + .set('kbn-xsrf', 'true') + .send({ + cases: updatedIndWithStatus.map((caseInfo) => ({ + id: caseInfo.id, + version: caseInfo.version, + settings: { syncAlerts: true }, + })), + }) + .expect(200); + + await es.indices.refresh({ index: defaultSignalsIndex }); + + signals = await getSignalsWithES({ + es, + indices: defaultSignalsIndex, + ids: [signalID, signalID2], + }); + + // alerts should be updated now that the + expect(signals.get(defaultSignalsIndex)?.get(signalID)?._source?.signal.status).to.be( + CaseStatuses.closed + ); + expect(signals.get(defaultSignalsIndex)?.get(signalID2)?._source?.signal.status).to.be( + CaseStatuses['in-progress'] + ); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/delete_comment.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/delete_comment.ts new file mode 100644 index 0000000000000..fc0b62ff924b5 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/delete_comment.ts @@ -0,0 +1,371 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { getPostCaseRequest, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + createComment, + deleteComment, + deleteAllComments, + superUserSpace1Auth, +} from '../../../../common/lib/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnly, + obsOnlyRead, + obsSecRead, + secOnly, + secOnlyRead, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('delete_comment', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + describe('happy path', () => { + it('should delete a comment', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + const comment = await deleteComment({ + supertest, + caseId: postedCase.id, + commentId: patchedCase.comments![0].id, + }); + + expect(comment).to.eql({}); + }); + }); + + describe('unhappy path', () => { + it('404s when comment belongs to different case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + const error = (await deleteComment({ + supertest, + caseId: 'fake-id', + commentId: patchedCase.comments![0].id, + expectedHttpCode: 404, + })) as Error; + + expect(error.message).to.be( + `This comment ${patchedCase.comments![0].id} does not exist in fake-id.` + ); + }); + + it('404s when comment is not there', async () => { + await deleteComment({ + supertest, + caseId: 'fake-id', + commentId: 'fake-id', + expectedHttpCode: 404, + }); + }); + + it('should return a 400 when attempting to delete all comments when passing the `subCaseId` parameter', async () => { + const { body } = await supertest + .delete(`${CASES_URL}/case-id/comments?subCaseId=value`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + // make sure the failure is because of the subCaseId + expect(body.message).to.contain('disabled'); + }); + + it('should return a 400 when attempting to delete a single comment when passing the `subCaseId` parameter', async () => { + const { body } = await supertest + .delete(`${CASES_URL}/case-id/comments/comment-id?subCaseId=value`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + // make sure the failure is because of the subCaseId + expect(body.message).to.contain('disabled'); + }); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + describe.skip('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('deletes a comment from a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .delete( + `${CASES_URL}/${caseInfo.id}/comments/${caseInfo.comments![0].id}?subCaseId=${ + caseInfo.subCases![0].id + }` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + const { body } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` + ); + + expect(body.length).to.eql(0); + }); + + it('deletes all comments from a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + let { body: allComments } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` + ); + expect(allComments.length).to.eql(2); + + await supertest + .delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + ({ body: allComments } = await supertest.get( + `${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}` + )); + + // no comments for the sub case + expect(allComments.length).to.eql(0); + + ({ body: allComments } = await supertest.get(`${CASES_URL}/${caseInfo.id}/comments`)); + + // no comments for the collection + expect(allComments.length).to.eql(0); + }); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should delete a comment from the appropriate owner', async () => { + const secCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: secOnly, space: 'space1' } + ); + + const commentResp = await createComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + params: postCommentUserReq, + auth: { user: secOnly, space: 'space1' }, + }); + + await deleteComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + commentId: commentResp.comments![0].id, + auth: { user: secOnly, space: 'space1' }, + }); + }); + + it('should delete multiple comments from the appropriate owner', async () => { + const secCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: secOnly, space: 'space1' } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + params: postCommentUserReq, + auth: { user: secOnly, space: 'space1' }, + }); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + params: postCommentUserReq, + auth: { user: secOnly, space: 'space1' }, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + auth: { user: secOnly, space: 'space1' }, + }); + }); + + it('should not delete a comment from a different owner', async () => { + const secCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: secOnly, space: 'space1' } + ); + + const commentResp = await createComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + params: postCommentUserReq, + auth: { user: secOnly, space: 'space1' }, + }); + + await deleteComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + commentId: commentResp.comments![0].id, + auth: { user: obsOnly, space: 'space1' }, + expectedHttpCode: 403, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + auth: { user: obsOnly, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + + for (const user of [globalRead, secOnlyRead, obsOnlyRead, obsSecRead, noKibanaPrivileges]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT delete a comment`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserSpace1Auth + ); + + const commentResp = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: superUserSpace1Auth, + }); + + await deleteComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + commentId: commentResp.comments![0].id, + auth: { user, space: 'space1' }, + expectedHttpCode: 403, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: { user, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + } + + it('should NOT delete a comment in a space with where the user does not have permissions', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: superUser, space: 'space2' } + ); + + const commentResp = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: superUser, space: 'space2' }, + }); + + await deleteComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + commentId: commentResp.comments![0].id, + auth: { user: secOnly, space: 'space2' }, + expectedHttpCode: 403, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: { user: secOnly, space: 'space2' }, + expectedHttpCode: 403, + }); + }); + + it('should NOT delete a comment created in space2 by making a request to space1', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: superUser, space: 'space2' } + ); + + const commentResp = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: superUser, space: 'space2' }, + }); + + await deleteComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + commentId: commentResp.comments![0].id, + auth: { user: secOnly, space: 'space1' }, + expectedHttpCode: 404, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: { user: secOnly, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/find_comments.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/find_comments.ts new file mode 100644 index 0000000000000..2ec99d039dd00 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/find_comments.ts @@ -0,0 +1,393 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { CommentsResponse, CommentType } from '../../../../../../plugins/cases/common/api'; +import { + getPostCaseRequest, + postCaseReq, + postCommentAlertReq, + postCommentUserReq, +} from '../../../../common/lib/mock'; +import { + createCaseAction, + createComment, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + ensureSavedObjectIsAuthorized, + getSpaceUrlPrefix, + createCase, + superUserSpace1Auth, +} from '../../../../common/lib/utils'; + +import { + obsOnly, + secOnly, + obsOnlyRead, + secOnlyRead, + noKibanaPrivileges, + superUser, + globalRead, + obsSecRead, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('find_comments', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should find all case comment', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + // post 2 comments + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: caseComments } = await supertest + .get(`${CASES_URL}/${postedCase.id}/comments/_find`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(caseComments.comments).to.eql(patchedCase.comments); + }); + + it('should filter case comments', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + // post 2 comments + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ ...postCommentUserReq, comment: 'unique' }) + .expect(200); + + const { body: caseComments } = await supertest + .get(`${CASES_URL}/${postedCase.id}/comments/_find?search=unique`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(caseComments.comments).to.eql([patchedCase.comments[1]]); + }); + + it('unhappy path - 400s when query is bad', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + await supertest + .get(`${CASES_URL}/${postedCase.id}/comments/_find?perPage=true`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + + it('should return a 400 when passing the subCaseId parameter', async () => { + const { body } = await supertest + .get(`${CASES_URL}/case-id/comments/_find?search=unique&subCaseId=value`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + + expect(body.message).to.contain('disabled'); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + describe.skip('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('finds comments for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: subCaseComments }: { body: CommentsResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseId=${caseInfo.subCases![0].id}`) + .send() + .expect(200); + expect(subCaseComments.total).to.be(2); + expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); + expect(subCaseComments.comments[1].type).to.be(CommentType.user); + }); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return the correct comments', async () => { + const space1 = 'space1'; + + const [secCase, obsCase] = await Promise.all([ + // Create case owned by the security solution user + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: secOnly, space: space1 } + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { user: obsOnly, space: space1 } + ), + // Create case owned by the observability user + ]); + + await Promise.all([ + createComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + params: postCommentUserReq, + auth: { user: secOnly, space: space1 }, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: obsCase.id, + params: { ...postCommentAlertReq, owner: 'observabilityFixture' }, + auth: { user: obsOnly, space: space1 }, + }), + ]); + + for (const scenario of [ + { + user: globalRead, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: secCase.id, + }, + { + user: globalRead, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: obsCase.id, + }, + { + user: superUser, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: secCase.id, + }, + { + user: superUser, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: obsCase.id, + }, + { + user: secOnlyRead, + numExpectedEntites: 1, + owners: ['securitySolutionFixture'], + caseID: secCase.id, + }, + { + user: obsOnlyRead, + numExpectedEntites: 1, + owners: ['observabilityFixture'], + caseID: obsCase.id, + }, + { + user: obsSecRead, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: secCase.id, + }, + { + user: obsSecRead, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: obsCase.id, + }, + ]) { + const { body: caseComments }: { body: CommentsResponse } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(space1)}${CASES_URL}/${scenario.caseID}/comments/_find`) + .auth(scenario.user.username, scenario.user.password) + .expect(200); + + ensureSavedObjectIsAuthorized( + caseComments.comments, + scenario.numExpectedEntites, + scenario.owners + ); + } + }); + + for (const scenario of [ + { user: noKibanaPrivileges, space: 'space1' }, + { user: secOnly, space: 'space2' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should NOT read a comment`, async () => { + // super user creates a case and comment in the appropriate space + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: superUser, space: scenario.space } + ); + + await createComment({ + supertest: supertestWithoutAuth, + auth: { user: superUser, space: scenario.space }, + params: { ...postCommentUserReq, owner: 'securitySolutionFixture' }, + caseId: caseInfo.id, + }); + + // user should not be able to read comments + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(scenario.space)}${CASES_URL}/${caseInfo.id}/comments/_find`) + .auth(scenario.user.username, scenario.user.password) + .expect(403); + }); + } + + it('should not return any comments when trying to exploit RBAC through the search query parameter', async () => { + const obsCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserSpace1Auth + ); + + await createComment({ + supertest: supertestWithoutAuth, + auth: superUserSpace1Auth, + params: { ...postCommentUserReq, owner: 'observabilityFixture' }, + caseId: obsCase.id, + }); + + const { body: res }: { body: CommentsResponse } = await supertestWithoutAuth + .get( + `${getSpaceUrlPrefix('space1')}${CASES_URL}/${ + obsCase.id + }/comments/_find?search=securitySolutionFixture+observabilityFixture` + ) + .auth(secOnly.username, secOnly.password) + .expect(200); + + // shouldn't find any comments since they were created under the observability ownership + ensureSavedObjectIsAuthorized(res.comments, 0, ['securitySolutionFixture']); + }); + + it('should not allow retrieving unauthorized comments using the filter field', async () => { + const obsCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserSpace1Auth + ); + + await createComment({ + supertest: supertestWithoutAuth, + auth: superUserSpace1Auth, + params: { ...postCommentUserReq, owner: 'observabilityFixture' }, + caseId: obsCase.id, + }); + + const { body: res } = await supertestWithoutAuth + .get( + `${getSpaceUrlPrefix('space1')}${CASES_URL}/${ + obsCase.id + }/comments/_find?filter=cases-comments.attributes.owner:"observabilityFixture"` + ) + .auth(secOnly.username, secOnly.password) + .expect(200); + expect(res.comments.length).to.be(0); + }); + + // This test ensures that the user is not allowed to define the namespaces query param + // so she cannot search across spaces + it('should NOT allow to pass a namespaces query parameter', async () => { + const obsCase = await createCase( + supertest, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200 + ); + + await createComment({ + supertest, + params: { ...postCommentUserReq, owner: 'observabilityFixture' }, + caseId: obsCase.id, + }); + + await supertest + .get(`${CASES_URL}/${obsCase.id}/comments/_find?namespaces[0]=*`) + .expect(400); + + await supertest.get(`${CASES_URL}/${obsCase.id}/comments/_find?namespaces=*`).expect(400); + }); + + it('should NOT allow to pass a non supported query parameter', async () => { + await supertest.get(`${CASES_URL}/id/comments/_find?notExists=papa`).expect(400); + await supertest.get(`${CASES_URL}/id/comments/_find?owner=papa`).expect(400); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts new file mode 100644 index 0000000000000..25df715b43e9a --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_all_comments.ts @@ -0,0 +1,231 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { postCaseReq, getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + createCase, + createComment, + getAllComments, + superUserSpace1Auth, +} from '../../../../common/lib/utils'; +import { CommentType } from '../../../../../../plugins/cases/common/api'; +import { + globalRead, + noKibanaPrivileges, + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + secOnly, + secOnlyRead, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_all_comments', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should get multiple comments for a single case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + const comments = await getAllComments({ supertest, caseId: postedCase.id }); + + expect(comments.length).to.eql(2); + }); + + it('should return a 400 when passing the subCaseId parameter', async () => { + const { body } = await supertest + .get(`${CASES_URL}/case-id/comments?subCaseId=value`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + + expect(body.message).to.contain('disabled'); + }); + + it('should return a 400 when passing the includeSubCaseComments parameter', async () => { + const { body } = await supertest + .get(`${CASES_URL}/case-id/comments?includeSubCaseComments=true`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + + expect(body.message).to.contain('disabled'); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + describe.skip('sub cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + + it('should get comments from a case and its sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments?includeSubCaseComments=true`) + .expect(200); + + expect(comments.length).to.eql(2); + expect(comments[0].type).to.eql(CommentType.generatedAlert); + expect(comments[1].type).to.eql(CommentType.user); + }); + + it('should get comments from a sub cases', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.subCases![0].id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: comments } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) + .expect(200); + + expect(comments.length).to.eql(2); + expect(comments[0].type).to.eql(CommentType.generatedAlert); + expect(comments[1].type).to.eql(CommentType.user); + }); + + it('should not find any comments for an invalid case id', async () => { + const { body } = await supertest + .get(`${CASES_URL}/fake-id/comments`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(0); + }); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should get all comments when the user has the correct permissions', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserSpace1Auth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserSpace1Auth, + }); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserSpace1Auth, + }); + + for (const user of [globalRead, superUser, secOnly, secOnlyRead, obsSec, obsSecRead]) { + const comments = await getAllComments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + auth: { user, space: 'space1' }, + }); + + expect(comments.length).to.eql(2); + } + }); + + it('should not get comments when the user does not have correct permission', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserSpace1Auth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserSpace1Auth, + }); + + for (const scenario of [ + { user: noKibanaPrivileges, returnCode: 403 }, + { user: obsOnly, returnCode: 200 }, + { user: obsOnlyRead, returnCode: 200 }, + ]) { + const comments = await getAllComments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + auth: { user: scenario.user, space: 'space1' }, + expectedHttpCode: scenario.returnCode, + }); + + // only check the length if we get a 200 in response + if (scenario.returnCode === 200) { + expect(comments.length).to.be(0); + } + } + }); + + it('should NOT get a comment in a space with no permissions', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: superUser, space: 'space2' } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: { user: superUser, space: 'space2' }, + }); + + await getAllComments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + auth: { user: secOnly, space: 'space2' }, + expectedHttpCode: 403, + }); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_comment.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_comment.ts new file mode 100644 index 0000000000000..5b606e06e84df --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/get_comment.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { postCaseReq, postCommentUserReq, getPostCaseRequest } from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + createCase, + createComment, + getComment, + superUserSpace1Auth, +} from '../../../../common/lib/utils'; +import { CommentType } from '../../../../../../plugins/cases/common/api'; +import { + globalRead, + noKibanaPrivileges, + obsOnly, + obsOnlyRead, + obsSec, + obsSecRead, + secOnly, + secOnlyRead, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_comment', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should get a comment', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + const comment = await getComment({ + supertest, + caseId: postedCase.id, + commentId: patchedCase.comments![0].id, + }); + + expect(comment).to.eql(patchedCase.comments![0]); + }); + + it('unhappy path - 404s when comment is not there', async () => { + await getComment({ + supertest, + caseId: 'fake-id', + commentId: 'fake-id', + expectedHttpCode: 404, + }); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + describe.skip('sub cases', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + it('should get a sub case comment', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const comment = await getComment({ + supertest, + caseId: caseInfo.id, + commentId: caseInfo.comments![0].id, + }); + expect(comment.type).to.be(CommentType.generatedAlert); + }); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should get a comment when the user has the correct permissions', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserSpace1Auth + ); + + const caseWithComment = await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserSpace1Auth, + }); + + for (const user of [globalRead, superUser, secOnly, secOnlyRead, obsSec, obsSecRead]) { + await getComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + commentId: caseWithComment.comments![0].id, + auth: { user, space: 'space1' }, + }); + } + }); + + it('should not get comment when the user does not have correct permissions', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserSpace1Auth + ); + + const caseWithComment = await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserSpace1Auth, + }); + + for (const user of [noKibanaPrivileges, obsOnly, obsOnlyRead]) { + await getComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + commentId: caseWithComment.comments![0].id, + auth: { user, space: 'space1' }, + expectedHttpCode: 403, + }); + } + }); + + it('should NOT get a case in a space with no permissions', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: superUser, space: 'space2' } + ); + + const caseWithComment = await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: { user: superUser, space: 'space2' }, + }); + + await getComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + commentId: caseWithComment.comments![0].id, + auth: { user: secOnly, space: 'space2' }, + expectedHttpCode: 403, + }); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts similarity index 93% rename from x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts index 264ac2a0898e0..50a219c5e84b3 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/comments/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/migrations.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts new file mode 100644 index 0000000000000..b00a0382bc712 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/patch_comment.ts @@ -0,0 +1,641 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { omit } from 'lodash/fp'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { + AttributesTypeAlerts, + AttributesTypeUser, + CaseResponse, + CommentType, +} from '../../../../../../plugins/cases/common/api'; +import { + defaultUser, + postCaseReq, + postCommentUserReq, + postCommentAlertReq, + getPostCaseRequest, +} from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + createComment, + updateComment, + superUserSpace1Auth, +} from '../../../../common/lib/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnly, + obsOnlyRead, + obsSecRead, + secOnly, + secOnlyRead, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('patch_comment', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should return a 400 when the subCaseId parameter is passed', async () => { + const { body } = await supertest + .patch(`${CASES_URL}/case-id}/comments?subCaseId=value`) + .set('kbn-xsrf', 'true') + .send({ + id: 'id', + version: 'version', + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + rule: { + id: 'id', + name: 'name', + }, + owner: 'securitySolutionFixture', + }) + .expect(400); + + expect(body.message).to.contain('disabled'); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + describe.skip('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('patches a comment for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + const { body: patchedSubCase }: { body: CaseResponse } = await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + const { body: patchedSubCaseUpdatedComment } = await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedSubCase.comments![1].id, + version: patchedSubCase.comments![1].version, + comment: newComment, + type: CommentType.user, + }) + .expect(200); + + expect(patchedSubCaseUpdatedComment.comments.length).to.be(2); + expect(patchedSubCaseUpdatedComment.comments[0].type).to.be(CommentType.generatedAlert); + expect(patchedSubCaseUpdatedComment.comments[1].type).to.be(CommentType.user); + expect(patchedSubCaseUpdatedComment.comments[1].comment).to.be(newComment); + }); + + it('fails to update the generated alert comment type', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) + .set('kbn-xsrf', 'true') + .send({ + id: caseInfo.comments![0].id, + version: caseInfo.comments![0].version, + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + rule: { + id: 'id', + name: 'name', + }, + }) + .expect(400); + }); + + it('fails to update the generated alert comment by using another generated alert comment', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + await supertest + .patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) + .set('kbn-xsrf', 'true') + .send({ + id: caseInfo.comments![0].id, + version: caseInfo.comments![0].version, + type: CommentType.generatedAlert, + alerts: [{ _id: 'id1' }], + index: 'test-index', + rule: { + id: 'id', + name: 'name', + }, + }) + .expect(400); + }); + }); + + it('should patch a comment', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + const updatedCase = await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + type: CommentType.user, + owner: 'securitySolutionFixture', + }, + }); + + const userComment = updatedCase.comments![0] as AttributesTypeUser; + expect(userComment.comment).to.eql(newComment); + expect(userComment.type).to.eql(CommentType.user); + expect(updatedCase.updated_by).to.eql(defaultUser); + }); + + it('should patch an alert', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertReq, + }); + const updatedCase = await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + type: CommentType.alert, + alertId: 'new-id', + index: postCommentAlertReq.index, + rule: { + id: 'id', + name: 'name', + }, + owner: 'securitySolutionFixture', + }, + }); + + const alertComment = updatedCase.comments![0] as AttributesTypeAlerts; + expect(alertComment.alertId).to.eql('new-id'); + expect(alertComment.index).to.eql(postCommentAlertReq.index); + expect(alertComment.type).to.eql(CommentType.alert); + expect(alertComment.rule).to.eql({ + id: 'id', + name: 'name', + }); + expect(alertComment.updated_by).to.eql(defaultUser); + }); + + it('should not allow updating the owner of a comment', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + + await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + type: CommentType.user, + comment: postCommentUserReq.comment, + owner: 'changedOwner', + }, + expectedHttpCode: 400, + }); + }); + + it('unhappy path - 404s when comment is not there', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: 'id', + version: 'version', + type: CommentType.user, + comment: 'comment', + owner: 'securitySolutionFixture', + }, + expectedHttpCode: 404, + }); + }); + + it('unhappy path - 404s when case is not there', async () => { + await updateComment({ + supertest, + caseId: 'fake-id', + req: { + id: 'id', + version: 'version', + type: CommentType.user, + comment: 'comment', + owner: 'securitySolutionFixture', + }, + expectedHttpCode: 404, + }); + }); + + it('unhappy path - 400s when trying to change comment type', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + + await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + rule: { + id: 'id', + name: 'name', + }, + owner: 'securitySolutionFixture', + }, + expectedHttpCode: 400, + }); + }); + + it('unhappy path - 400s when missing attributes for type user', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + + await updateComment({ + supertest, + caseId: postedCase.id, + // @ts-expect-error + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + }, + expectedHttpCode: 400, + }); + }); + + it('unhappy path - 400s when adding excess attributes for type user', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + + for (const attribute of ['alertId', 'index']) { + await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: 'a comment', + type: CommentType.user, + [attribute]: attribute, + owner: 'securitySolutionFixture', + }, + expectedHttpCode: 400, + }); + } + }); + + it('unhappy path - 400s when missing attributes for type alert', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertReq, + }); + + const allRequestAttributes = { + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + rule: { + id: 'id', + name: 'name', + }, + }; + + for (const attribute of ['alertId', 'index']) { + const requestAttributes = omit(attribute, allRequestAttributes); + await updateComment({ + supertest, + caseId: postedCase.id, + // @ts-expect-error + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + ...requestAttributes, + }, + expectedHttpCode: 400, + }); + } + }); + + it('unhappy path - 400s when adding excess attributes for type alert', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertReq, + }); + + for (const attribute of ['comment']) { + await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + rule: { + id: 'id', + name: 'name', + }, + owner: 'securitySolutionFixture', + [attribute]: attribute, + }, + expectedHttpCode: 400, + }); + } + }); + + it('unhappy path - 409s when conflict', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: patchedCase.comments![0].id, + version: 'version-mismatch', + type: CommentType.user, + comment: newComment, + owner: 'securitySolutionFixture', + }, + expectedHttpCode: 409, + }); + }); + + describe('alert format', () => { + type AlertComment = CommentType.alert | CommentType.generatedAlert; + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed create a test case for generated alerts here + for (const [alertId, index, type] of [ + ['1', ['index1', 'index2'], CommentType.alert], + [['1', '2'], 'index', CommentType.alert], + ]) { + it(`throws an error with an alert comment with contents id: ${alertId} indices: ${index} type: ${type}`, async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertReq, + }); + + await updateComment({ + supertest, + caseId: patchedCase.id, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + type: type as AlertComment, + alertId, + index, + owner: 'securitySolutionFixture', + rule: postCommentAlertReq.rule, + }, + expectedHttpCode: 400, + }); + }); + } + + for (const [alertId, index, type] of [ + ['1', ['index1'], CommentType.alert], + [['1', '2'], ['index', 'other-index'], CommentType.alert], + ]) { + it(`does not throw an error with an alert comment with contents id: ${alertId} indices: ${index} type: ${type}`, async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: { + ...postCommentAlertReq, + alertId, + index, + owner: 'securitySolutionFixture', + type: type as AlertComment, + }, + }); + + await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + type: type as AlertComment, + alertId, + index, + owner: 'securitySolutionFixture', + rule: postCommentAlertReq.rule, + }, + }); + }); + } + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should update a comment that the user has permissions for', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserSpace1Auth + ); + + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: superUserSpace1Auth, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + const updatedCase = await updateComment({ + supertest, + caseId: postedCase.id, + req: { + ...postCommentUserReq, + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + }, + auth: { user: secOnly, space: 'space1' }, + }); + + const userComment = updatedCase.comments![0] as AttributesTypeUser; + expect(userComment.comment).to.eql(newComment); + expect(userComment.type).to.eql(CommentType.user); + expect(updatedCase.updated_by).to.eql(defaultUser); + expect(userComment.owner).to.eql('securitySolutionFixture'); + }); + + it('should not update a comment that has a different owner thant he user has access to', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserSpace1Auth + ); + + const patchedCase = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: superUserSpace1Auth, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + await updateComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + req: { + ...postCommentUserReq, + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + }, + auth: { user: obsOnly, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + + for (const user of [globalRead, secOnlyRead, obsOnlyRead, obsSecRead, noKibanaPrivileges]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT update a comment`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserSpace1Auth + ); + + const patchedCase = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: superUserSpace1Auth, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + await updateComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + req: { + ...postCommentUserReq, + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + }, + auth: { user, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + } + + it('should not update a comment in a space the user does not have permissions', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: superUser, space: 'space2' } + ); + + const patchedCase = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: superUser, space: 'space2' }, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + await updateComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + req: { + ...postCommentUserReq, + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + }, + auth: { user: secOnly, space: 'space2' }, + // getting the case will fail in the saved object layer with a 403 + expectedHttpCode: 403, + }); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/post_comment.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/post_comment.ts new file mode 100644 index 0000000000000..a1f24de1b87da --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/comments/post_comment.ts @@ -0,0 +1,605 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { omit } from 'lodash/fp'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../../plugins/security_solution/common/constants'; +import { + CommentsResponse, + CommentType, + AttributesTypeUser, + AttributesTypeAlerts, +} from '../../../../../../plugins/cases/common/api'; +import { + defaultUser, + postCaseReq, + postCommentUserReq, + postCommentAlertReq, + postCollectionReq, + postCommentGenAlertReq, + getPostCaseRequest, +} from '../../../../common/lib/mock'; +import { + createCaseAction, + createSubCase, + deleteAllCaseItems, + deleteCaseAction, + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + createComment, + getCaseUserActions, + removeServerGeneratedPropertiesFromUserAction, + removeServerGeneratedPropertiesFromSavedObject, + superUserSpace1Auth, +} from '../../../../common/lib/utils'; +import { + createSignalsIndex, + deleteSignalsIndex, + deleteAllAlerts, + getRuleForSignalTesting, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, + getSignalsByIds, + createRule, + getQuerySignalIds, +} from '../../../../../detection_engine_api_integration/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnly, + obsOnlyRead, + obsSecRead, + secOnly, + secOnlyRead, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('post_comment', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + describe('happy path', () => { + it('should post a comment', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + const comment = removeServerGeneratedPropertiesFromSavedObject( + patchedCase.comments![0] as AttributesTypeUser + ); + + expect(comment).to.eql({ + type: postCommentUserReq.type, + comment: postCommentUserReq.comment, + associationType: 'case', + created_by: defaultUser, + pushed_at: null, + pushed_by: null, + updated_by: null, + owner: 'securitySolutionFixture', + }); + + // updates the case correctly after adding a comment + expect(patchedCase.totalComment).to.eql(patchedCase.comments!.length); + expect(patchedCase.updated_by).to.eql(defaultUser); + }); + + it('should post an alert', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertReq, + }); + const comment = removeServerGeneratedPropertiesFromSavedObject( + patchedCase.comments![0] as AttributesTypeAlerts + ); + + expect(comment).to.eql({ + type: postCommentAlertReq.type, + alertId: postCommentAlertReq.alertId, + index: postCommentAlertReq.index, + rule: postCommentAlertReq.rule, + associationType: 'case', + created_by: defaultUser, + pushed_at: null, + pushed_by: null, + updated_by: null, + owner: 'securitySolutionFixture', + }); + + // updates the case correctly after adding a comment + expect(patchedCase.totalComment).to.eql(patchedCase.comments!.length); + expect(patchedCase.updated_by).to.eql(defaultUser); + }); + + it('creates a user action', async () => { + const postedCase = await createCase(supertest, postCaseReq); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + }); + const userActions = await getCaseUserActions({ supertest, caseID: postedCase.id }); + const commentUserAction = removeServerGeneratedPropertiesFromUserAction(userActions[1]); + + expect(commentUserAction).to.eql({ + action_field: ['comment'], + action: 'create', + action_by: defaultUser, + new_value: `{"comment":"${postCommentUserReq.comment}","type":"${postCommentUserReq.type}","owner":"securitySolutionFixture"}`, + old_value: null, + case_id: `${postedCase.id}`, + comment_id: `${patchedCase.comments![0].id}`, + sub_case_id: '', + owner: 'securitySolutionFixture', + }); + }); + }); + + describe('unhappy path', () => { + it('400s when attempting to create a comment with a different owner than the case', async () => { + const postedCase = await createCase( + supertest, + getPostCaseRequest({ owner: 'securitySolutionFixture' }) + ); + + await createComment({ + supertest, + caseId: postedCase.id, + params: { ...postCommentUserReq, owner: 'observabilityFixture' }, + expectedHttpCode: 400, + }); + }); + + it('400s when type is missing', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await createComment({ + supertest, + caseId: postedCase.id, + params: { + // @ts-expect-error + bad: 'comment', + }, + expectedHttpCode: 400, + }); + }); + + it('400s when missing attributes for type user', async () => { + const postedCase = await createCase(supertest, postCaseReq); + await createComment({ + supertest, + caseId: postedCase.id, + // @ts-expect-error + params: { + type: CommentType.user, + }, + expectedHttpCode: 400, + }); + }); + + it('400s when adding excess attributes for type user', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + for (const attribute of ['alertId', 'index']) { + await createComment({ + supertest, + caseId: postedCase.id, + params: { + type: CommentType.user, + [attribute]: attribute, + comment: 'a comment', + owner: 'securitySolutionFixture', + }, + expectedHttpCode: 400, + }); + } + }); + + it('400s when missing attributes for type alert', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + const allRequestAttributes = { + type: CommentType.alert, + index: 'test-index', + alertId: 'test-id', + rule: { + id: 'id', + name: 'name', + }, + owner: 'securitySolutionFixture', + }; + + for (const attribute of ['alertId', 'index']) { + const requestAttributes = omit(attribute, allRequestAttributes); + await createComment({ + supertest, + caseId: postedCase.id, + // @ts-expect-error + params: requestAttributes, + expectedHttpCode: 400, + }); + } + }); + + it('400s when adding excess attributes for type alert', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + for (const attribute of ['comment']) { + await createComment({ + supertest, + caseId: postedCase.id, + params: { + type: CommentType.alert, + [attribute]: attribute, + alertId: 'test-id', + index: 'test-index', + rule: { + id: 'id', + name: 'name', + }, + owner: 'securitySolutionFixture', + }, + expectedHttpCode: 400, + }); + } + }); + + it('400s when case is missing', async () => { + await createComment({ + supertest, + caseId: 'not-exists', + params: { + // @ts-expect-error + bad: 'comment', + }, + expectedHttpCode: 400, + }); + }); + + it('400s when adding an alert to a closed case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(200); + + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertReq, + expectedHttpCode: 400, + }); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + it.skip('400s when adding an alert to a collection case', async () => { + const postedCase = await createCase(supertest, postCollectionReq); + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertReq, + expectedHttpCode: 400, + }); + }); + + it('400s when adding a generated alert to an individual case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentGenAlertReq) + .expect(400); + }); + + it('should return a 400 when passing the subCaseId', async () => { + const { body } = await supertest + .post(`${CASES_URL}/case-id/comments?subCaseId=value`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(400); + expect(body.message).to.contain('subCaseId'); + }); + }); + + describe('alerts', () => { + beforeEach(async () => { + await esArchiver.load('auditbeat/hosts'); + await createSignalsIndex(supertest); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await esArchiver.unload('auditbeat/hosts'); + }); + + it('should change the status of the alert if sync alert is on', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + const postedCase = await createCase(supertest, postCaseReq); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'in-progress', + }, + ], + }) + .expect(200); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + await createComment({ + supertest, + caseId: postedCase.id, + params: { + alertId: alert._id, + index: alert._index, + rule: { + id: 'id', + name: 'name', + }, + owner: 'securitySolutionFixture', + type: CommentType.alert, + }, + }); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('in-progress'); + }); + + it('should NOT change the status of the alert if sync alert is off', async () => { + const rule = getRuleForSignalTesting(['auditbeat-*']); + const postedCase = await createCase(supertest, { + ...postCaseReq, + settings: { syncAlerts: false }, + }); + + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'in-progress', + }, + ], + }) + .expect(200); + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signals = await getSignalsByIds(supertest, [id]); + + const alert = signals.hits.hits[0]; + expect(alert._source.signal.status).eql('open'); + + await createComment({ + supertest, + caseId: postedCase.id, + params: { + alertId: alert._id, + index: alert._index, + rule: { + id: 'id', + name: 'name', + }, + owner: 'securitySolutionFixture', + type: CommentType.alert, + }, + }); + + const { body: updatedAlert } = await supertest + .post(DETECTION_ENGINE_QUERY_SIGNALS_URL) + .set('kbn-xsrf', 'true') + .send(getQuerySignalIds([alert._id])) + .expect(200); + + expect(updatedAlert.hits.hits[0]._source.signal.status).eql('open'); + }); + }); + + describe('alert format', () => { + type AlertComment = CommentType.alert | CommentType.generatedAlert; + + for (const [alertId, index, type] of [ + ['1', ['index1', 'index2'], CommentType.alert], + [['1', '2'], 'index', CommentType.alert], + ['1', ['index1', 'index2'], CommentType.generatedAlert], + [['1', '2'], 'index', CommentType.generatedAlert], + ]) { + it(`throws an error with an alert comment with contents id: ${alertId} indices: ${index} type: ${type}`, async () => { + const postedCase = await createCase(supertest, postCaseReq); + await createComment({ + supertest, + caseId: postedCase.id, + params: { ...postCommentAlertReq, alertId, index, type: type as AlertComment }, + expectedHttpCode: 400, + }); + }); + } + + for (const [alertId, index, type] of [ + ['1', ['index1'], CommentType.alert], + [['1', '2'], ['index', 'other-index'], CommentType.alert], + ]) { + it(`does not throw an error with an alert comment with contents id: ${alertId} indices: ${index} type: ${type}`, async () => { + const postedCase = await createCase(supertest, postCaseReq); + await createComment({ + supertest, + caseId: postedCase.id, + params: { + ...postCommentAlertReq, + alertId, + index, + type: type as AlertComment, + }, + expectedHttpCode: 200, + }); + }); + } + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + describe.skip('sub case comments', () => { + let actionID: string; + before(async () => { + actionID = await createCaseAction(supertest); + }); + after(async () => { + await deleteCaseAction(supertest, actionID); + }); + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('posts a new comment for a sub case', async () => { + const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID }); + // create another sub case just to make sure we get the right comments + await createSubCase({ supertest, actionID }); + await supertest + .post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body: subCaseComments }: { body: CommentsResponse } = await supertest + .get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseId=${caseInfo.subCases![0].id}`) + .send() + .expect(200); + expect(subCaseComments.total).to.be(2); + expect(subCaseComments.comments[0].type).to.be(CommentType.generatedAlert); + expect(subCaseComments.comments[1].type).to.be(CommentType.user); + }); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should create a comment when the user has the correct permissions for that owner', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserSpace1Auth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: secOnly, space: 'space1' }, + }); + }); + + it('should not create a comment when the user does not have permissions for that owner', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + { user: obsOnly, space: 'space1' } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: { ...postCommentUserReq, owner: 'observabilityFixture' }, + auth: { user: secOnly, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + + for (const user of [globalRead, secOnlyRead, obsOnlyRead, obsSecRead, noKibanaPrivileges]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should not create a comment`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserSpace1Auth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + } + + it('should not create a comment in a space the user does not have permissions for', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { user: superUser, space: 'space2' } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: secOnly, space: 'space2' }, + expectedHttpCode: 403, + }); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/get_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/get_configure.ts new file mode 100644 index 0000000000000..279936ebbef46 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/get_configure.ts @@ -0,0 +1,215 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + deleteConfiguration, + getConfiguration, + createConfiguration, + getConfigurationRequest, + ensureSavedObjectIsAuthorized, +} from '../../../../common/lib/utils'; +import { + obsOnly, + secOnly, + obsOnlyRead, + secOnlyRead, + noKibanaPrivileges, + superUser, + globalRead, + obsSecRead, + obsSec, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('get_configure', () => { + afterEach(async () => { + await deleteConfiguration(es); + }); + + it('should return an empty find body correctly if no configuration is loaded', async () => { + const configuration = await getConfiguration({ supertest }); + expect(configuration).to.eql([]); + }); + + it('should return a configuration', async () => { + await createConfiguration(supertest); + const configuration = await getConfiguration({ supertest }); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql(getConfigurationOutput()); + }); + + it('should get a single configuration', async () => { + await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); + await createConfiguration(supertest); + const res = await getConfiguration({ supertest }); + + expect(res.length).to.eql(1); + const data = removeServerGeneratedPropertiesFromSavedObject(res[0]); + expect(data).to.eql(getConfigurationOutput()); + }); + + it('should return by descending order', async () => { + await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); + await createConfiguration(supertest); + const res = await getConfiguration({ supertest }); + + const data = removeServerGeneratedPropertiesFromSavedObject(res[0]); + expect(data).to.eql(getConfigurationOutput()); + }); + + describe('rbac', () => { + it('should return the correct configuration', async () => { + await createConfiguration(supertestWithoutAuth, getConfigurationRequest(), 200, { + user: secOnly, + space: 'space1', + }); + + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 200, + { + user: obsOnly, + space: 'space1', + } + ); + + for (const scenario of [ + { + user: globalRead, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + { + user: superUser, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + { user: secOnlyRead, numberOfExpectedCases: 1, owners: ['securitySolutionFixture'] }, + { user: obsOnlyRead, numberOfExpectedCases: 1, owners: ['observabilityFixture'] }, + { + user: obsSecRead, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + ]) { + const configuration = await getConfiguration({ + supertest: supertestWithoutAuth, + query: { owner: scenario.owners }, + expectedHttpCode: 200, + auth: { + user: scenario.user, + space: 'space1', + }, + }); + + ensureSavedObjectIsAuthorized( + configuration, + scenario.numberOfExpectedCases, + scenario.owners + ); + } + }); + + for (const scenario of [ + { user: noKibanaPrivileges, space: 'space1' }, + { user: secOnly, space: 'space2' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should NOT read a case configuration`, async () => { + // super user creates a configuration at the appropriate space + await createConfiguration(supertestWithoutAuth, getConfigurationRequest(), 200, { + user: superUser, + space: scenario.space, + }); + + // user should not be able to read configurations at the appropriate space + await getConfiguration({ + supertest: supertestWithoutAuth, + expectedHttpCode: 403, + auth: { + user: scenario.user, + space: scenario.space, + }, + }); + }); + } + + it('should respect the owner filter when having permissions', async () => { + await Promise.all([ + createConfiguration(supertestWithoutAuth, getConfigurationRequest(), 200, { + user: obsSec, + space: 'space1', + }), + createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 200, + { + user: obsSec, + space: 'space1', + } + ), + ]); + + const res = await getConfiguration({ + supertest: supertestWithoutAuth, + query: { owner: 'securitySolutionFixture' }, + auth: { + user: obsSec, + space: 'space1', + }, + }); + + ensureSavedObjectIsAuthorized(res, 1, ['securitySolutionFixture']); + }); + + it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => { + await Promise.all([ + createConfiguration(supertestWithoutAuth, getConfigurationRequest(), 200, { + user: obsSec, + space: 'space1', + }), + createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 200, + { + user: obsSec, + space: 'space1', + } + ), + ]); + + // User with permissions only to security solution request cases from observability + const res = await getConfiguration({ + supertest: supertestWithoutAuth, + query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, + auth: { + user: secOnly, + space: 'space1', + }, + }); + + // Only security solution cases are being returned + ensureSavedObjectIsAuthorized(res, 1, ['securitySolutionFixture']); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/get_connectors.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/get_connectors.ts new file mode 100644 index 0000000000000..46f712ff84aa3 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/get_connectors.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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { getCaseConnectors } from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + + describe('get_connectors', () => { + it('should return an empty find body correctly if no connectors are loaded', async () => { + const connectors = await getCaseConnectors({ supertest }); + expect(connectors).to.eql([]); + }); + + it.skip('filters out connectors that are not enabled in license', async () => { + // TODO: Should find a way to downgrade license to gold and upgrade back to trial + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/configure/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts similarity index 76% rename from x-pack/test/case_api_integration/basic/tests/configure/migrations.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts index 4ee2021399fae..cc2f6c414503d 100644 --- a/x-pack/test/case_api_integration/basic/tests/configure/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts @@ -6,8 +6,8 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../common/ftr_provider_context'; -import { CASE_CONFIGURE_URL } from '../../../../../plugins/cases/common/constants'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { CASE_CONFIGURE_URL } from '../../../../../../plugins/cases/common/constants'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { @@ -30,9 +30,10 @@ export default function createGetTests({ getService }: FtrProviderContext) { .send() .expect(200); - expect(body).key('connector'); - expect(body).not.key('connector_id'); - expect(body.connector).to.eql({ + expect(body.length).to.be(1); + expect(body[0]).key('connector'); + expect(body[0]).not.key('connector_id'); + expect(body[0].connector).to.eql({ id: 'connector-1', name: 'Connector 1', type: '.none', diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts new file mode 100644 index 0000000000000..323b1b377e555 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts @@ -0,0 +1,269 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; + +import { + getConfigurationRequest, + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + deleteConfiguration, + createConfiguration, + updateConfiguration, +} from '../../../../common/lib/utils'; +import { + secOnly, + obsOnlyRead, + secOnlyRead, + noKibanaPrivileges, + globalRead, + obsSecRead, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('patch_configure', () => { + const actionsRemover = new ActionsRemover(supertest); + + afterEach(async () => { + await deleteConfiguration(es); + await actionsRemover.removeAll(); + }); + + it('should patch a configuration', async () => { + const configuration = await createConfiguration(supertest); + const newConfiguration = await updateConfiguration(supertest, configuration.id, { + closure_type: 'close-by-pushing', + version: configuration.version, + }); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ ...getConfigurationOutput(true), closure_type: 'close-by-pushing' }); + }); + + it('should not patch a configuration with unsupported connector type', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + getConfigurationRequest({ type: '.unsupported' }), + 400 + ); + }); + + it('should not patch a configuration with unsupported connector fields', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + getConfigurationRequest({ type: '.jira', fields: { unsupported: 'value' } }), + 400 + ); + }); + + it('should handle patch request when there is no configuration', async () => { + const error = await updateConfiguration( + supertest, + 'not-exist', + { closure_type: 'close-by-pushing', version: 'no-version' }, + 404 + ); + + expect(error).to.eql({ + error: 'Not Found', + message: 'Saved object [cases-configure/not-exist] not found', + statusCode: 404, + }); + }); + + it('should handle patch request when versions are different', async () => { + const configuration = await createConfiguration(supertest); + const error = await updateConfiguration( + supertest, + configuration.id, + { closure_type: 'close-by-pushing', version: 'no-version' }, + 409 + ); + + expect(error).to.eql({ + error: 'Conflict', + message: + 'This configuration has been updated. Please refresh before saving additional updates.', + statusCode: 409, + }); + }); + + it('should not allow to change the owner of the configuration', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + { owner: 'observabilityFixture', version: configuration.version }, + 400 + ); + }); + + it('should not allow excess attributes', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + { notExist: 'not-exist', version: configuration.version }, + 400 + ); + }); + + describe('rbac', () => { + it('User: security solution only - should update a configuration', async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + { + user: secOnly, + space: 'space1', + } + ); + + const newConfiguration = await updateConfiguration( + supertestWithoutAuth, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 200, + { + user: secOnly, + space: 'space1', + } + ); + + expect(newConfiguration.owner).to.eql('securitySolutionFixture'); + }); + + it('User: security solution only - should NOT update a configuration of different owner', async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 200, + { + user: superUser, + space: 'space1', + } + ); + + await updateConfiguration( + supertestWithoutAuth, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 403, + { + user: secOnly, + space: 'space1', + } + ); + }); + + for (const user of [globalRead, secOnlyRead, obsOnlyRead, obsSecRead, noKibanaPrivileges]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT update a configuration`, async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await updateConfiguration( + supertestWithoutAuth, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 403, + { + user, + space: 'space1', + } + ); + }); + } + + it('should NOT update a configuration in a space with no permissions', async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'securitySolutionFixture' }, + 200, + { + user: superUser, + space: 'space2', + } + ); + + await updateConfiguration( + supertestWithoutAuth, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 403, + { + user: secOnly, + space: 'space2', + } + ); + }); + + it('should NOT update a configuration created in space2 by making a request to space1', async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'securitySolutionFixture' }, + 200, + { + user: superUser, + space: 'space2', + } + ); + + await updateConfiguration( + supertestWithoutAuth, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 404, + { + user: secOnly, + space: 'space1', + } + ); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/post_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/post_configure.ts new file mode 100644 index 0000000000000..44ec24f688f20 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/post_configure.ts @@ -0,0 +1,315 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; + +import { + getConfigurationRequest, + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + deleteConfiguration, + createConfiguration, + getConfiguration, + ensureSavedObjectIsAuthorized, +} from '../../../../common/lib/utils'; + +import { + secOnly, + obsOnlyRead, + secOnlyRead, + noKibanaPrivileges, + globalRead, + obsSecRead, + superUser, + testDisabled, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('post_configure', () => { + const actionsRemover = new ActionsRemover(supertest); + + afterEach(async () => { + await deleteConfiguration(es); + await actionsRemover.removeAll(); + }); + + it('should create a configuration', async () => { + const configuration = await createConfiguration(supertest); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration); + expect(data).to.eql(getConfigurationOutput()); + }); + + it('should keep only the latest configuration', async () => { + await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); + await createConfiguration(supertest); + const configuration = await getConfiguration({ supertest }); + + expect(configuration.length).to.be(1); + }); + + it('should return an error when failing to get mapping', async () => { + const postRes = await createConfiguration( + supertest, + getConfigurationRequest({ + id: 'not-exists', + name: 'not-exists', + type: ConnectorTypes.jira, + }) + ); + + expect(postRes.error).to.not.be(null); + expect(postRes.mappings).to.eql([]); + }); + + it('should not create a configuration when missing connector.id', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + name: 'Connector', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', + }, + 400 + ); + }); + + it('should not create a configuration when missing connector.name', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + id: 'test-id', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', + }, + 400 + ); + }); + + it('should not create a configuration when missing connector.type', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + id: 'test-id', + name: 'Connector', + fields: null, + }, + closure_type: 'close-by-user', + }, + 400 + ); + }); + + it('should not create a configuration when missing connector.fields', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + id: 'test-id', + type: ConnectorTypes.none, + name: 'Connector', + }, + closure_type: 'close-by-user', + }, + 400 + ); + }); + + it('should not create a configuration when when missing closure_type', async () => { + await createConfiguration( + supertest, + // @ts-expect-error + { + connector: { + id: 'test-id', + type: ConnectorTypes.none, + name: 'Connector', + fields: null, + }, + }, + 400 + ); + }); + + it('should not create a configuration when missing connector', async () => { + await createConfiguration( + supertest, + // @ts-expect-error + { + closure_type: 'close-by-user', + }, + 400 + ); + }); + + it('should not create a configuration when fields are not null', async () => { + await createConfiguration( + supertest, + { + connector: { + id: 'test-id', + type: ConnectorTypes.none, + name: 'Connector', + // @ts-expect-error + fields: {}, + }, + closure_type: 'close-by-user', + }, + 400 + ); + }); + + it('should not create a configuration with unsupported connector type', async () => { + // @ts-expect-error + await createConfiguration(supertest, getConfigurationRequest({ type: '.unsupported' }), 400); + }); + + it('should not create a configuration with unsupported connector fields', async () => { + await createConfiguration( + supertest, + // @ts-expect-error + getConfigurationRequest({ type: '.jira', fields: { unsupported: 'value' } }), + 400 + ); + }); + + describe('rbac', () => { + it('returns a 403 when attempting to create a configuration with an owner that was from a disabled feature in the space', async () => { + const configuration = ((await createConfiguration( + supertestWithoutAuth, + getConfigurationRequest({ overrides: { owner: 'testDisabledFixture' } }), + 403, + { + user: testDisabled, + space: 'space1', + } + )) as unknown) as { message: string }; + + expect(configuration.message).to.eql( + 'Unauthorized to create case configuration with owners: "testDisabledFixture"' + ); + }); + + it('User: security solution only - should create a configuration', async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + { + user: secOnly, + space: 'space1', + } + ); + + expect(configuration.owner).to.eql('securitySolutionFixture'); + }); + + it('User: security solution only - should NOT create a configuration of different owner', async () => { + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 403, + { + user: secOnly, + space: 'space1', + } + ); + }); + + for (const user of [globalRead, secOnlyRead, obsOnlyRead, obsSecRead, noKibanaPrivileges]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT create a configuration`, async () => { + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'securitySolutionFixture' }, + 403, + { + user, + space: 'space1', + } + ); + }); + } + + it('should NOT create a configuration in a space with no permissions', async () => { + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'securitySolutionFixture' }, + 403, + { + user: secOnly, + space: 'space2', + } + ); + }); + + it('it deletes the correct configurations', async () => { + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'securitySolutionFixture' }, + 200, + { + user: superUser, + space: 'space1', + } + ); + + /** + * This API call should not delete the previously created configuration + * as it belongs to a different owner + */ + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 200, + { + user: superUser, + space: 'space1', + } + ); + + const configuration = await getConfiguration({ + supertest: supertestWithoutAuth, + query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, + auth: { + user: superUser, + space: 'space1', + }, + }); + + /** + * This ensures that both configuration are returned as expected + * and neither of has been deleted + */ + ensureSavedObjectIsAuthorized(configuration, 2, [ + 'securitySolutionFixture', + 'observabilityFixture', + ]); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/case.ts similarity index 92% rename from x-pack/test/case_api_integration/basic/tests/connectors/case.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/case.ts index 0f9cba4b51f75..fd9ec8142b49f 100644 --- a/x-pack/test/case_api_integration/basic/tests/connectors/case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/connectors/case.ts @@ -7,16 +7,15 @@ import { omit } from 'lodash/fp'; import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { CASES_URL } from '../../../../../plugins/cases/common/constants'; -import { CommentType } from '../../../../../plugins/cases/common/api'; +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { CommentType } from '../../../../../../plugins/cases/common/api'; +import { postCaseReq, postCaseResp } from '../../../../common/lib/mock'; import { - postCaseReq, - postCaseResp, removeServerGeneratedPropertiesFromCase, removeServerGeneratedPropertiesFromComments, -} from '../../../common/lib/mock'; +} from '../../../../common/lib/utils'; import { createRule, createSignalsIndex, @@ -26,7 +25,7 @@ import { getSignalsByIds, waitForRuleSuccessOrStatus, waitForSignalsToBePresent, -} from '../../../../detection_engine_api_integration/utils'; +} from '../../../../../detection_engine_api_integration/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -38,7 +37,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should return 400 when creating a case action', async () => { await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -51,7 +50,7 @@ export default ({ getService }: FtrProviderContext): void => { // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests it.skip('should return 200 when creating a case action successfully', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -71,7 +70,7 @@ export default ({ getService }: FtrProviderContext): void => { }); const { body: fetchedAction } = await supertest - .get(`/api/actions/action/${createdActionId}`) + .get(`/api/actions/connector/${createdActionId}`) .expect(200); expect(fetchedAction).to.eql({ @@ -86,7 +85,7 @@ export default ({ getService }: FtrProviderContext): void => { describe.skip('create', () => { it('should respond with a 400 Bad Request when creating a case without title', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -115,7 +114,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -131,7 +130,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when creating a case without description', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -160,7 +159,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -176,7 +175,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when creating a case without tags', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -205,7 +204,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -221,7 +220,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when creating a case without connector', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -241,7 +240,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -257,7 +256,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when creating jira without issueType', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -286,7 +285,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -302,7 +301,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when creating a connector with wrong fields', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -332,7 +331,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -348,7 +347,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when creating a none without fields as null', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -374,7 +373,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -390,7 +389,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should create a case', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -423,7 +422,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -448,7 +447,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should create a case with connector with field as null if not provided', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -477,7 +476,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -516,7 +515,7 @@ export default ({ getService }: FtrProviderContext): void => { describe.skip('update', () => { it('should respond with a 400 Bad Request when updating a case without id', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -535,7 +534,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -551,7 +550,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when updating a case without version', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -570,7 +569,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -586,7 +585,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should update a case', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -613,7 +612,7 @@ export default ({ getService }: FtrProviderContext): void => { }; await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -640,7 +639,7 @@ export default ({ getService }: FtrProviderContext): void => { describe.skip('addComment', () => { it('should respond with a 400 Bad Request when adding a comment to a case without caseId', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -658,7 +657,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -674,7 +673,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when missing attributes of type user', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -692,7 +691,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -719,7 +718,6 @@ export default ({ getService }: FtrProviderContext): void => { }); it('should add a comment of type alert', async () => { - // TODO: don't do all this stuff const rule = getRuleForSignalTesting(['auditbeat-*']); const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); @@ -728,7 +726,7 @@ export default ({ getService }: FtrProviderContext): void => { const alert = signals.hits.hits[0]; const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -759,7 +757,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -790,7 +788,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when missing attributes of type alert', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -818,7 +816,7 @@ export default ({ getService }: FtrProviderContext): void => { for (const attribute of ['alertId']) { const requestAttributes = omit(attribute, comment); const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { @@ -839,7 +837,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when adding excess attributes for type user', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -859,7 +857,7 @@ export default ({ getService }: FtrProviderContext): void => { for (const attribute of ['blah', 'bogus']) { const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { @@ -882,7 +880,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when adding excess attributes for type alert', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -907,7 +905,7 @@ export default ({ getService }: FtrProviderContext): void => { for (const attribute of ['comment']) { const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { @@ -931,7 +929,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should respond with a 400 Bad Request when adding a comment to a case without type', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -950,7 +948,7 @@ export default ({ getService }: FtrProviderContext): void => { }; const caseConnector = await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -966,7 +964,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should add a comment of type user', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -992,7 +990,7 @@ export default ({ getService }: FtrProviderContext): void => { }; await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); @@ -1019,7 +1017,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should add a comment of type alert', async () => { const { body: createdAction } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A case connector', @@ -1050,7 +1048,7 @@ export default ({ getService }: FtrProviderContext): void => { }; await supertest - .post(`/api/actions/action/${createdActionId}/_execute`) + .post(`/api/actions/connector/${createdActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params }) .expect(200); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/index.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/index.ts new file mode 100644 index 0000000000000..9d35d5ec82fc5 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('Common', function () { + loadTestFile(require.resolve('./client/update_alert_status')); + loadTestFile(require.resolve('./comments/delete_comment')); + loadTestFile(require.resolve('./comments/find_comments')); + loadTestFile(require.resolve('./comments/get_comment')); + loadTestFile(require.resolve('./comments/get_all_comments')); + loadTestFile(require.resolve('./comments/patch_comment')); + loadTestFile(require.resolve('./comments/post_comment')); + loadTestFile(require.resolve('./alerts/get_cases')); + loadTestFile(require.resolve('./cases/delete_cases')); + loadTestFile(require.resolve('./cases/find_cases')); + loadTestFile(require.resolve('./cases/get_case')); + loadTestFile(require.resolve('./cases/patch_cases')); + loadTestFile(require.resolve('./cases/post_case')); + loadTestFile(require.resolve('./cases/reporters/get_reporters')); + loadTestFile(require.resolve('./cases/status/get_status')); + loadTestFile(require.resolve('./cases/tags/get_tags')); + loadTestFile(require.resolve('./user_actions/get_all_user_actions')); + loadTestFile(require.resolve('./configure/get_configure')); + loadTestFile(require.resolve('./configure/get_connectors')); + loadTestFile(require.resolve('./configure/patch_configure')); + loadTestFile(require.resolve('./configure/post_configure')); + loadTestFile(require.resolve('./connectors/case')); + loadTestFile(require.resolve('./sub_cases/patch_sub_cases')); + loadTestFile(require.resolve('./sub_cases/delete_sub_cases')); + loadTestFile(require.resolve('./sub_cases/get_sub_case')); + loadTestFile(require.resolve('./sub_cases/find_sub_cases')); + + // NOTE: Migrations are not included because they can inadvertently remove the .kibana indices which removes the users and spaces + // which causes errors in any tests after them that relies on those + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.ts new file mode 100644 index 0000000000000..17d93e76bbdda --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/migrations.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 { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('Common migrations', function () { + // Migrations + loadTestFile(require.resolve('./cases/migrations')); + loadTestFile(require.resolve('./configure/migrations')); + loadTestFile(require.resolve('./user_actions/migrations')); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/delete_sub_cases.ts similarity index 98% rename from x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/delete_sub_cases.ts index 15b3b9311e3c3..951db263a6c78 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/delete_sub_cases.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/delete_sub_cases.ts @@ -5,7 +5,7 @@ * 2.0. */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; import { CASES_URL, diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/find_sub_cases.ts similarity index 99% rename from x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/find_sub_cases.ts index 14c0460c7583b..d54523bec0c4d 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/find_sub_cases.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/find_sub_cases.ts @@ -298,6 +298,7 @@ export default ({ getService }: FtrProviderContext): void => { { _id: `${i}`, _index: 'test-index', ruleId: 'rule-id', ruleName: 'rule name' }, ]), type: CommentType.generatedAlert, + owner: 'securitySolutionFixture', }; responses.push( await createSubCase({ diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/get_sub_case.ts similarity index 95% rename from x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/get_sub_case.ts index 8d4ffafbf763a..35ed4ba5c3c71 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/get_sub_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/get_sub_case.ts @@ -6,21 +6,17 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { - commentsResp, - postCommentAlertReq, - removeServerGeneratedPropertiesFromComments, - removeServerGeneratedPropertiesFromSubCase, - subCaseResp, -} from '../../../../common/lib/mock'; +import { commentsResp, postCommentAlertReq, subCaseResp } from '../../../../common/lib/mock'; import { createCaseAction, createSubCase, defaultCreateSubComment, deleteAllCaseItems, deleteCaseAction, + removeServerGeneratedPropertiesFromComments, + removeServerGeneratedPropertiesFromSubCase, } from '../../../../common/lib/utils'; import { getCaseCommentsUrl, diff --git a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/patch_sub_cases.ts similarity index 98% rename from x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/patch_sub_cases.ts index d993a627d186b..442644463fa38 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/sub_cases/patch_sub_cases.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/sub_cases/patch_sub_cases.ts @@ -5,7 +5,7 @@ * 2.0. */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; import { CASES_URL, @@ -100,6 +100,7 @@ export default function ({ getService }: FtrProviderContext) { }, ]), type: CommentType.generatedAlert, + owner: 'securitySolutionFixture', }, }); @@ -156,6 +157,7 @@ export default function ({ getService }: FtrProviderContext) { }, ]), type: CommentType.generatedAlert, + owner: 'securitySolutionFixture', }, }); @@ -225,6 +227,7 @@ export default function ({ getService }: FtrProviderContext) { }, ]), type: CommentType.generatedAlert, + owner: 'securitySolutionFixture', }, }); @@ -243,6 +246,7 @@ export default function ({ getService }: FtrProviderContext) { }, ]), type: CommentType.generatedAlert, + owner: 'securitySolutionFixture', }, }); @@ -354,6 +358,7 @@ export default function ({ getService }: FtrProviderContext) { }, ]), type: CommentType.generatedAlert, + owner: 'securitySolutionFixture', }, }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts similarity index 73% rename from x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts index a3bc2a4399db2..35ebb1a4bf7b1 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts @@ -8,52 +8,46 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; -import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../../plugins/cases/common/constants'; -import { CommentType } from '../../../../../../plugins/cases/common/api'; +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { + CaseResponse, + CaseStatuses, + CommentType, +} from '../../../../../../plugins/cases/common/api'; import { userActionPostResp, - defaultUser, postCaseReq, postCommentUserReq, + getPostCaseRequest, } from '../../../../common/lib/mock'; import { - deleteCases, - deleteCasesUserActions, - deleteComments, - deleteConfiguration, - getConfiguration, - getServiceNowConnector, + deleteAllCaseItems, + createCase, + updateCase, + getCaseUserActions, + superUserSpace1Auth, } from '../../../../common/lib/utils'; - -import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { - ExternalServiceSimulator, - getExternalServiceSimulatorPath, -} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; + globalRead, + noKibanaPrivileges, + obsSec, + obsSecRead, + secOnly, + secOnlyRead, + superUser, +} from '../../../../common/lib/authentication/users'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - const actionsRemover = new ActionsRemover(supertest); - const kibanaServer = getService('kibanaServer'); describe('get_all_user_actions', () => { - let servicenowSimulatorURL: string = ''; - before(() => { - servicenowSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) - ); - }); afterEach(async () => { - await deleteCases(es); - await deleteComments(es); - await deleteConfiguration(es); - await deleteCasesUserActions(es); - await actionsRemover.removeAll(); + await deleteAllCaseItems(es); }); - it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector', 'settings]`, async () => { + it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title', 'connector', 'settings, owner]`, async () => { const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -75,6 +69,7 @@ export default ({ getService }: FtrProviderContext): void => { 'title', 'connector', 'settings', + 'owner', ]); expect(body[0].action).to.eql('create'); expect(body[0].old_value).to.eql(null); @@ -314,12 +309,17 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); const newComment = 'Well I decided to update my comment. So what? Deal with it.'; - await supertest.patch(`${CASES_URL}/${postedCase.id}/comments`).set('kbn-xsrf', 'true').send({ - id: patchedCase.comments[0].id, - version: patchedCase.comments[0].version, - comment: newComment, - type: CommentType.user, - }); + await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedCase.comments[0].id, + version: patchedCase.comments[0].version, + comment: newComment, + type: CommentType.user, + owner: 'securitySolutionFixture', + }) + .expect(200); const { body } = await supertest .get(`${CASES_URL}/${postedCase.id}/user_actions`) @@ -334,72 +334,62 @@ export default ({ getService }: FtrProviderContext): void => { expect(JSON.parse(body[2].new_value)).to.eql({ comment: newComment, type: CommentType.user, + owner: 'securitySolutionFixture', }); }); - it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { - const { body: connector } = await supertest - .post('/api/actions/action') - .set('kbn-xsrf', 'true') - .send({ - ...getServiceNowConnector(), - config: { apiUrl: servicenowSimulatorURL }, - }) - .expect(200); - - actionsRemover.add('default', connector.id, 'action', 'actions'); - - const { body: configure } = await supertest - .post(CASE_CONFIGURE_URL) - .set('kbn-xsrf', 'true') - .send( - getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - }) - ) - .expect(200); - - const { body: postedCase } = await supertest - .post(CASES_URL) - .set('kbn-xsrf', 'true') - .send({ - ...postCaseReq, - connector: getConfiguration({ - id: connector.id, - name: connector.name, - type: connector.actionTypeId, - fields: { - urgency: '2', - impact: '2', - severity: '2', - category: 'software', - subcategory: 'os', - }, - }).connector, - }) - .expect(200); + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + let caseInfo: CaseResponse; + beforeEach(async () => { + caseInfo = await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { + user: superUser, + space: 'space1', + }); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: caseInfo.id, + version: caseInfo.version, + status: CaseStatuses.closed, + }, + ], + }, + auth: superUserSpace1Auth, + }); + }); - await supertest - .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) - .set('kbn-xsrf', 'true') - .send({}) - .expect(200); + it('should get the user actions for a case when the user has the correct permissions', async () => { + for (const user of [globalRead, superUser, secOnly, secOnlyRead, obsSec, obsSecRead]) { + const userActions = await getCaseUserActions({ + supertest: supertestWithoutAuth, + caseID: caseInfo.id, + auth: { user, space: 'space1' }, + }); - const { body } = await supertest - .get(`${CASES_URL}/${postedCase.id}/user_actions`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); + expect(userActions.length).to.eql(2); + } + }); - expect(body.length).to.eql(2); - expect(body[1].action_field).to.eql(['pushed']); - expect(body[1].action).to.eql('push-to-service'); - expect(body[1].old_value).to.eql(null); - const newValue = JSON.parse(body[1].new_value); - expect(newValue.connector_id).to.eql(configure.connector.id); - expect(newValue.pushed_by).to.eql(defaultUser); + for (const scenario of [ + { user: noKibanaPrivileges, space: 'space1' }, + { user: secOnly, space: 'space2' }, + ]) { + it(`should 403 when requesting the user actions of a case with user ${ + scenario.user.username + } with role(s) ${scenario.user.roles.join()} and space ${scenario.space}`, async () => { + await getCaseUserActions({ + supertest: supertestWithoutAuth, + caseID: caseInfo.id, + auth: { user: scenario.user, space: scenario.space }, + expectedHttpCode: 403, + }); + }); + } }); }); }; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts similarity index 95% rename from x-pack/test/case_api_integration/basic/tests/cases/user_actions/migrations.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts index d0f852b3f57e7..e198260e88a9c 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/user_actions/migrations.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts new file mode 100644 index 0000000000000..8a58c59718feb --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts @@ -0,0 +1,315 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; + +import { + postCaseReq, + defaultUser, + postCommentUserReq, + getPostCaseRequest, +} from '../../../../common/lib/mock'; +import { + getConfigurationRequest, + createCase, + pushCase, + createComment, + updateCase, + getCaseUserActions, + removeServerGeneratedPropertiesFromUserAction, + deleteAllCaseItems, + superUserSpace1Auth, + createCaseWithConnector, +} from '../../../../common/lib/utils'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; +import { CaseStatuses, CaseUserActionResponse } from '../../../../../../plugins/cases/common/api'; +import { + globalRead, + noKibanaPrivileges, + obsOnlyRead, + obsSecRead, + secOnly, + secOnlyRead, + superUser, +} from '../../../../common/lib/authentication/users'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const es = getService('es'); + + describe('push_case', () => { + const actionsRemover = new ActionsRemover(supertest); + + let servicenowSimulatorURL: string = ''; + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + + afterEach(async () => { + await deleteAllCaseItems(es); + await actionsRemover.removeAll(); + }); + + it('should push a case', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + const theCase = await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: connector.id, + }); + + const { pushed_at, external_url, ...rest } = theCase.external_service!; + + expect(rest).to.eql({ + pushed_by: defaultUser, + connector_id: connector.id, + connector_name: connector.name, + external_id: '123', + external_title: 'INC01', + }); + + // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins + expect( + external_url.includes( + 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' + ) + ).to.equal(true); + }); + + it('pushes a comment appropriately', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + await createComment({ supertest, caseId: postedCase.id, params: postCommentUserReq }); + const theCase = await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: connector.id, + }); + + expect(theCase.comments![0].pushed_by).to.eql(defaultUser); + }); + + it('should pushes a case and closes when closure_type: close-by-pushing', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + configureReq: { + closure_type: 'close-by-pushing', + }, + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + const theCase = await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: connector.id, + }); + + expect(theCase.status).to.eql('closed'); + }); + + it('should create the correct user action', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + const pushedCase = await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: connector.id, + }); + const userActions = await getCaseUserActions({ supertest, caseID: pushedCase.id }); + const pushUserAction = removeServerGeneratedPropertiesFromUserAction(userActions[1]); + + const { new_value, ...rest } = pushUserAction as CaseUserActionResponse; + const parsedNewValue = JSON.parse(new_value!); + + expect(rest).to.eql({ + action_field: ['pushed'], + action: 'push-to-service', + action_by: defaultUser, + old_value: null, + case_id: `${postedCase.id}`, + comment_id: null, + sub_case_id: '', + owner: 'securitySolutionFixture', + }); + + expect(parsedNewValue).to.eql({ + pushed_at: pushedCase.external_service!.pushed_at, + pushed_by: defaultUser, + connector_id: connector.id, + connector_name: connector.name, + external_id: '123', + external_title: 'INC01', + external_url: `${servicenowSimulatorURL}/nav_to.do?uri=incident.do?sys_id=123`, + }); + }); + + // ENABLE_CASE_CONNECTOR: once the case connector feature is completed unskip these tests + it.skip('should push a collection case but not close it when closure_type: close-by-pushing', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + configureReq: { + closure_type: 'close-by-pushing', + }, + }); + + const theCase = await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: connector.id, + }); + expect(theCase.status).to.eql(CaseStatuses.open); + }); + + it('unhappy path - 404s when case does not exist', async () => { + await pushCase({ + supertest, + caseId: 'fake-id', + connectorId: 'fake-connector', + expectedHttpCode: 404, + }); + }); + + it('unhappy path - 404s when connector does not exist', async () => { + const postedCase = await createCase(supertest, { + ...postCaseReq, + connector: getConfigurationRequest().connector, + }); + await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: 'fake-connector', + expectedHttpCode: 404, + }); + }); + + it('unhappy path = 409s when case is closed', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: CaseStatuses.closed, + }, + ], + }, + }); + + await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: connector.id, + expectedHttpCode: 409, + }); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should push a case that the user has permissions for', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + auth: superUserSpace1Auth, + }); + + await pushCase({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + connectorId: connector.id, + auth: { user: secOnly, space: 'space1' }, + }); + }); + + it('should not push a case that the user does not have permissions for', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + auth: superUserSpace1Auth, + createCaseReq: getPostCaseRequest({ owner: 'observabilityFixture' }), + }); + + await pushCase({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + connectorId: connector.id, + auth: { user: secOnly, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + + for (const user of [globalRead, secOnlyRead, obsOnlyRead, obsSecRead, noKibanaPrivileges]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT push a case`, async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + auth: superUserSpace1Auth, + }); + + await pushCase({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + connectorId: connector.id, + auth: { user, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + } + + it('should not push a case in a space that the user does not have permissions for', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + auth: { user: superUser, space: 'space2' }, + }); + + await pushCase({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + connectorId: connector.id, + auth: { user: secOnly, space: 'space2' }, + expectedHttpCode: 403, + }); + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts new file mode 100644 index 0000000000000..3729b20f82b30 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/user_actions/get_all_user_actions.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../../../common/ftr_provider_context'; + +import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../../../plugins/cases/common/constants'; +import { defaultUser, postCaseReq } from '../../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + deleteConfiguration, + getConfigurationRequest, + getServiceNowConnector, +} from '../../../../../common/lib/utils'; + +import { ObjectRemover as ActionsRemover } from '../../../../../../alerting_api_integration/common/lib'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const actionsRemover = new ActionsRemover(supertest); + const kibanaServer = getService('kibanaServer'); + + describe('get_all_user_actions', () => { + let servicenowSimulatorURL: string = ''; + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteConfiguration(es); + await deleteCasesUserActions(es); + await actionsRemover.removeAll(); + }); + + it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { + const { body: connector } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'true') + .send({ + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }) + .expect(200); + + actionsRemover.add('default', connector.id, 'action', 'actions'); + + const { body: configure } = await supertest + .post(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + .send( + getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id, + }) + ) + .expect(200); + + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + ...postCaseReq, + connector: getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, + }).connector, + }) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) + .set('kbn-xsrf', 'true') + .send({}) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body.length).to.eql(2); + expect(body[1].action_field).to.eql(['pushed']); + expect(body[1].action).to.eql('push-to-service'); + expect(body[1].old_value).to.eql(null); + const newValue = JSON.parse(body[1].new_value); + expect(newValue.connector_id).to.eql(configure.connector.id); + expect(newValue.pushed_by).to.eql(defaultUser); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts new file mode 100644 index 0000000000000..ff8f1cff884af --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_configure.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; + +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; +import { + getServiceNowConnector, + createConnector, + createConfiguration, + getConfiguration, + getConfigurationRequest, + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, +} from '../../../../common/lib/utils'; +import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const actionsRemover = new ActionsRemover(supertest); + const kibanaServer = getService('kibanaServer'); + + describe('get_configure', () => { + let servicenowSimulatorURL: string = ''; + + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + + afterEach(async () => { + await actionsRemover.removeAll(); + }); + + it('should return a configuration with mapping', async () => { + const connector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + }); + actionsRemover.add('default', connector.id, 'action', 'actions'); + + await createConfiguration( + supertest, + getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + }) + ); + + const configuration = await getConfiguration({ supertest }); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql( + getConfigurationOutput(false, { + mappings: [ + { + action_type: 'overwrite', + source: 'title', + target: 'short_description', + }, + { + action_type: 'overwrite', + source: 'description', + target: 'description', + }, + { + action_type: 'append', + source: 'comments', + target: 'work_notes', + }, + ], + connector: { + id: connector.id, + name: connector.name, + type: connector.connector_type_id, + fields: null, + }, + }) + ); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts similarity index 75% rename from x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts rename to x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts index 7eaa5ca609cf8..fb922f8d10243 100644 --- a/x-pack/test/case_api_integration/basic/tests/configure/get_connectors.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/get_connectors.ts @@ -6,15 +6,17 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; -import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../plugins/cases/common/constants'; -import { ObjectRemover as ActionsRemover } from '../../../../alerting_api_integration/common/lib'; +import { CASE_CONFIGURE_CONNECTORS_URL } from '../../../../../../plugins/cases/common/constants'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; import { getServiceNowConnector, getJiraConnector, getResilientConnector, -} from '../../../common/lib/utils'; + createConnector, + getServiceNowSIRConnector, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -26,29 +28,19 @@ export default ({ getService }: FtrProviderContext): void => { await actionsRemover.removeAll(); }); - it('should return an empty find body correctly if no connectors are loaded', async () => { - const { body } = await supertest - .get(`${CASE_CONFIGURE_CONNECTORS_URL}/_find`) - .set('kbn-xsrf', 'true') - .send() - .expect(200); - - expect(body).to.eql([]); - }); - it('should return the correct connectors', async () => { const { body: snConnector } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'true') .send(getServiceNowConnector()) .expect(200); const { body: emailConnector } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'true') .send({ name: 'An email action', - actionTypeId: '.email', + connector_type_id: '.email', config: { service: '__json', from: 'bob@example.com', @@ -61,17 +53,20 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); const { body: jiraConnector } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'true') .send(getJiraConnector()) .expect(200); const { body: resilientConnector } = await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'true') .send(getResilientConnector()) .expect(200); + const sir = await createConnector({ supertest, req: getServiceNowSIRConnector() }); + + actionsRemover.add('default', sir.id, 'action', 'actions'); actionsRemover.add('default', snConnector.id, 'action', 'actions'); actionsRemover.add('default', emailConnector.id, 'action', 'actions'); actionsRemover.add('default', jiraConnector.id, 'action', 'actions'); @@ -82,6 +77,7 @@ export default ({ getService }: FtrProviderContext): void => { .set('kbn-xsrf', 'true') .send() .expect(200); + expect(connectors).to.eql([ { id: jiraConnector.id, @@ -118,6 +114,15 @@ export default ({ getService }: FtrProviderContext): void => { isMissingSecrets: false, referencedByCount: 0, }, + { + id: sir.id, + actionTypeId: '.servicenow-sir', + name: 'ServiceNow Connector', + config: { apiUrl: 'http://some.non.existent.com' }, + isPreconfigured: false, + isMissingSecrets: false, + referencedByCount: 0, + }, ]); }); }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/index.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/index.ts new file mode 100644 index 0000000000000..0c8c3931d1577 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('configuration tests', function () { + loadTestFile(require.resolve('./get_configure')); + loadTestFile(require.resolve('./get_connectors')); + loadTestFile(require.resolve('./patch_configure')); + loadTestFile(require.resolve('./post_configure')); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts new file mode 100644 index 0000000000000..789b68b19beb6 --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/patch_configure.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; + +import { + getConfigurationRequest, + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + deleteConfiguration, + createConfiguration, + updateConfiguration, + getServiceNowConnector, + createConnector, +} from '../../../../common/lib/utils'; +import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const kibanaServer = getService('kibanaServer'); + + describe('patch_configure', () => { + const actionsRemover = new ActionsRemover(supertest); + let servicenowSimulatorURL: string = ''; + + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + + afterEach(async () => { + await deleteConfiguration(es); + await actionsRemover.removeAll(); + }); + + it('should patch a configuration connector and create mappings', async () => { + const connector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + }); + + actionsRemover.add('default', connector.id, 'action', 'actions'); + + // Configuration is created with no connector so the mappings are empty + const configuration = await createConfiguration(supertest); + + // the update request doesn't accept the owner field + const { owner, ...reqWithoutOwner } = getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + fields: null, + }); + + const newConfiguration = await updateConfiguration(supertest, configuration.id, { + ...reqWithoutOwner, + version: configuration.version, + }); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ + ...getConfigurationOutput(true), + connector: { + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + fields: null, + }, + mappings: [ + { + action_type: 'overwrite', + source: 'title', + target: 'short_description', + }, + { + action_type: 'overwrite', + source: 'description', + target: 'description', + }, + { + action_type: 'append', + source: 'comments', + target: 'work_notes', + }, + ], + }); + }); + + it('should mappings when updating the connector', async () => { + const connector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + }); + + actionsRemover.add('default', connector.id, 'action', 'actions'); + + // Configuration is created with connector so the mappings are created + const configuration = await createConfiguration( + supertest, + getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + }) + ); + + // the update request doesn't accept the owner field + const { owner, ...rest } = getConfigurationRequest({ + id: connector.id, + name: 'New name', + type: connector.connector_type_id as ConnectorTypes, + fields: null, + }); + + const newConfiguration = await updateConfiguration(supertest, configuration.id, { + ...rest, + version: configuration.version, + }); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ + ...getConfigurationOutput(true), + connector: { + id: connector.id, + name: 'New name', + type: connector.connector_type_id as ConnectorTypes, + fields: null, + }, + mappings: [ + { + action_type: 'overwrite', + source: 'title', + target: 'short_description', + }, + { + action_type: 'overwrite', + source: 'description', + target: 'description', + }, + { + action_type: 'append', + source: 'comments', + target: 'work_notes', + }, + ], + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts new file mode 100644 index 0000000000000..96ffcf4bc3f5c --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/configure/post_configure.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; + +import { + getConfigurationRequest, + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + deleteConfiguration, + createConfiguration, + createConnector, + getServiceNowConnector, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const kibanaServer = getService('kibanaServer'); + + describe('post_configure', () => { + const actionsRemover = new ActionsRemover(supertest); + let servicenowSimulatorURL: string = ''; + + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + + afterEach(async () => { + await deleteConfiguration(es); + await actionsRemover.removeAll(); + }); + + it('should create a configuration with mapping', async () => { + const connector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + }); + + actionsRemover.add('default', connector.id, 'action', 'actions'); + + const postRes = await createConfiguration( + supertest, + getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + }) + ); + + const data = removeServerGeneratedPropertiesFromSavedObject(postRes); + expect(data).to.eql( + getConfigurationOutput(false, { + mappings: [ + { + action_type: 'overwrite', + source: 'title', + target: 'short_description', + }, + { + action_type: 'overwrite', + source: 'description', + target: 'description', + }, + { + action_type: 'append', + source: 'comments', + target: 'work_notes', + }, + ], + connector: { + id: connector.id, + name: connector.name, + type: connector.connector_type_id, + fields: null, + }, + }) + ); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/index.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/index.ts new file mode 100644 index 0000000000000..26bc6a072450d --- /dev/null +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { createSpacesAndUsers, deleteSpacesAndUsers } from '../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('cases security and spaces enabled: trial', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + await createSpacesAndUsers(getService); + }); + + after(async () => { + await deleteSpacesAndUsers(getService); + }); + + // Trial + loadTestFile(require.resolve('./cases/push_case')); + loadTestFile(require.resolve('./cases/user_actions/get_all_user_actions')); + loadTestFile(require.resolve('./configure/index')); + + // Common + loadTestFile(require.resolve('../common')); + + // NOTE: These need to be at the end because they could delete the .kibana index and inadvertently remove the users and spaces + loadTestFile(require.resolve('../common/migrations')); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/config.ts b/x-pack/test/case_api_integration/security_only/config.ts new file mode 100644 index 0000000000000..5946b8d25b464 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/config.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('security_only', { + disabledPlugins: ['spaces'], + license: 'trial', + ssl: true, + testFiles: [require.resolve('./tests/trial')], +}); diff --git a/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts b/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts new file mode 100644 index 0000000000000..9575bd99112f6 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/alerts/get_cases.ts @@ -0,0 +1,242 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/mock'; +import { + createCase, + createComment, + getCaseIDsByAlert, + deleteAllCaseItems, +} from '../../../../common/lib/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + secOnlyReadSpacesAll, + superUser, +} from '../../../../common/lib/authentication/users'; +import { + obsOnlyDefaultSpaceAuth, + secOnlyDefaultSpaceAuth, + superUserDefaultSpaceAuth, + obsSecDefaultSpaceAuth, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_cases using alertID', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should return the correct case IDs', async () => { + const [case1, case2, case3] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyDefaultSpaceAuth), + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyDefaultSpaceAuth), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyDefaultSpaceAuth + ), + ]); + + await Promise.all([ + createComment({ + supertest: supertestWithoutAuth, + caseId: case1.id, + params: postCommentAlertReq, + auth: secOnlyDefaultSpaceAuth, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: case2.id, + params: postCommentAlertReq, + auth: secOnlyDefaultSpaceAuth, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: case3.id, + params: { ...postCommentAlertReq, owner: 'observabilityFixture' }, + auth: obsOnlyDefaultSpaceAuth, + }), + ]); + + for (const scenario of [ + { + user: globalRead, + caseIDs: [case1.id, case2.id, case3.id], + }, + { + user: superUser, + caseIDs: [case1.id, case2.id, case3.id], + }, + { user: secOnlyReadSpacesAll, caseIDs: [case1.id, case2.id] }, + { user: obsOnlyReadSpacesAll, caseIDs: [case3.id] }, + { + user: obsSecReadSpacesAll, + caseIDs: [case1.id, case2.id, case3.id], + }, + ]) { + const res = await getCaseIDsByAlert({ + supertest: supertestWithoutAuth, + // cast because the official type is string | string[] but the ids will always be a single value in the tests + alertID: postCommentAlertReq.alertId as string, + auth: { + user: scenario.user, + space: null, + }, + }); + expect(res.length).to.eql(scenario.caseIDs.length); + for (const caseID of scenario.caseIDs) { + expect(res).to.contain(caseID); + } + } + }); + + it(`User ${ + noKibanaPrivileges.username + } with role(s) ${noKibanaPrivileges.roles.join()} - should not get cases`, async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest(), 200, { + user: superUser, + space: null, + }); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentAlertReq, + auth: superUserDefaultSpaceAuth, + }); + + await getCaseIDsByAlert({ + supertest: supertestWithoutAuth, + alertID: postCommentAlertReq.alertId as string, + auth: { user: noKibanaPrivileges, space: null }, + expectedHttpCode: 403, + }); + }); + + it('should return a 404 when attempting to access a space', async () => { + const [case1, case2] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, obsSecDefaultSpaceAuth), + createCase( + supertestWithoutAuth, + { ...getPostCaseRequest(), owner: 'observabilityFixture' }, + 200, + obsSecDefaultSpaceAuth + ), + ]); + + await Promise.all([ + createComment({ + supertest: supertestWithoutAuth, + caseId: case1.id, + params: postCommentAlertReq, + auth: obsSecDefaultSpaceAuth, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: case2.id, + params: { ...postCommentAlertReq, owner: 'observabilityFixture' }, + auth: obsSecDefaultSpaceAuth, + }), + ]); + + await getCaseIDsByAlert({ + supertest: supertestWithoutAuth, + alertID: postCommentAlertReq.alertId as string, + auth: { user: obsSecSpacesAll, space: 'space1' }, + query: { owner: 'securitySolutionFixture' }, + expectedHttpCode: 404, + }); + }); + + it('should respect the owner filter when have permissions', async () => { + const [case1, case2] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, obsSecDefaultSpaceAuth), + createCase( + supertestWithoutAuth, + { ...getPostCaseRequest(), owner: 'observabilityFixture' }, + 200, + obsSecDefaultSpaceAuth + ), + ]); + + await Promise.all([ + createComment({ + supertest: supertestWithoutAuth, + caseId: case1.id, + params: postCommentAlertReq, + auth: obsSecDefaultSpaceAuth, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: case2.id, + params: { ...postCommentAlertReq, owner: 'observabilityFixture' }, + auth: obsSecDefaultSpaceAuth, + }), + ]); + + const res = await getCaseIDsByAlert({ + supertest: supertestWithoutAuth, + alertID: postCommentAlertReq.alertId as string, + auth: obsSecDefaultSpaceAuth, + query: { owner: 'securitySolutionFixture' }, + }); + + expect(res).to.eql([case1.id]); + }); + + it('should return the correct case IDs when the owner query parameter contains unprivileged values', async () => { + const [case1, case2] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, obsSecDefaultSpaceAuth), + createCase( + supertestWithoutAuth, + { ...getPostCaseRequest(), owner: 'observabilityFixture' }, + 200, + obsSecDefaultSpaceAuth + ), + ]); + + await Promise.all([ + createComment({ + supertest: supertestWithoutAuth, + caseId: case1.id, + params: postCommentAlertReq, + auth: obsSecDefaultSpaceAuth, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: case2.id, + params: { ...postCommentAlertReq, owner: 'observabilityFixture' }, + auth: obsSecDefaultSpaceAuth, + }), + ]); + + const res = await getCaseIDsByAlert({ + supertest: supertestWithoutAuth, + alertID: postCommentAlertReq.alertId as string, + auth: secOnlyDefaultSpaceAuth, + // The secOnlyDefaultSpace user does not have permissions for observability cases, so it should only return the security solution one + query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, + }); + + expect(res).to.eql([case1.id]); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/cases/delete_cases.ts b/x-pack/test/case_api_integration/security_only/tests/common/cases/delete_cases.ts new file mode 100644 index 0000000000000..9ece177b21491 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/cases/delete_cases.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + deleteCases, + getCase, +} from '../../../../common/lib/utils'; +import { + secOnlySpacesAll, + secOnlyReadSpacesAll, + globalRead, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, +} from '../../../../common/lib/authentication/users'; +import { + obsOnlyDefaultSpaceAuth, + secOnlyDefaultSpaceAuth, + superUserDefaultSpaceAuth, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('delete_cases', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('User: security solution only - should delete a case', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + secOnlyDefaultSpaceAuth + ); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + expectedHttpCode: 204, + auth: secOnlyDefaultSpaceAuth, + }); + }); + + it('User: security solution only - should NOT delete a case of different owner', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + secOnlyDefaultSpaceAuth + ); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + expectedHttpCode: 403, + auth: obsOnlyDefaultSpaceAuth, + }); + }); + + it('should get an error if the user has not permissions to all requested cases', async () => { + const caseSec = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + secOnlyDefaultSpaceAuth + ); + + const caseObs = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyDefaultSpaceAuth + ); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [caseSec.id, caseObs.id], + expectedHttpCode: 403, + auth: obsOnlyDefaultSpaceAuth, + }); + + // Cases should have not been deleted. + await getCase({ + supertest: supertestWithoutAuth, + caseId: caseSec.id, + expectedHttpCode: 200, + auth: superUserDefaultSpaceAuth, + }); + + await getCase({ + supertest: supertestWithoutAuth, + caseId: caseObs.id, + expectedHttpCode: 200, + auth: superUserDefaultSpaceAuth, + }); + }); + + for (const user of [ + globalRead, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + ]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT delete a case`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + expectedHttpCode: 403, + auth: { user, space: null }, + }); + }); + } + + it('should return a 404 when attempting to access a space', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await deleteCases({ + supertest: supertestWithoutAuth, + caseIDs: [postedCase.id], + expectedHttpCode: 404, + auth: { user: secOnlySpacesAll, space: 'space1' }, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/cases/find_cases.ts b/x-pack/test/case_api_integration/security_only/tests/common/cases/find_cases.ts new file mode 100644 index 0000000000000..711eccbe16278 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/cases/find_cases.ts @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + ensureSavedObjectIsAuthorized, + findCases, + createCase, +} from '../../../../common/lib/utils'; +import { + secOnlySpacesAll, + obsOnlyReadSpacesAll, + secOnlyReadSpacesAll, + noKibanaPrivileges, + superUser, + globalRead, + obsSecReadSpacesAll, +} from '../../../../common/lib/authentication/users'; +import { + obsOnlyDefaultSpaceAuth, + obsSecDefaultSpaceAuth, + secOnlyDefaultSpaceAuth, + superUserDefaultSpaceAuth, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('find_cases', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return the correct cases', async () => { + await Promise.all([ + // Create case owned by the security solution user + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + secOnlyDefaultSpaceAuth + ), + // Create case owned by the observability user + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyDefaultSpaceAuth + ), + ]); + + for (const scenario of [ + { + user: globalRead, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + { + user: superUser, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + { + user: secOnlyReadSpacesAll, + numberOfExpectedCases: 1, + owners: ['securitySolutionFixture'], + }, + { + user: obsOnlyReadSpacesAll, + numberOfExpectedCases: 1, + owners: ['observabilityFixture'], + }, + { + user: obsSecReadSpacesAll, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + ]) { + const res = await findCases({ + supertest: supertestWithoutAuth, + auth: { + user: scenario.user, + space: null, + }, + }); + + ensureSavedObjectIsAuthorized(res.cases, scenario.numberOfExpectedCases, scenario.owners); + } + }); + + it(`User ${ + noKibanaPrivileges.username + } with role(s) ${noKibanaPrivileges.roles.join()} - should NOT read a case`, async () => { + await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, superUserDefaultSpaceAuth); + + await findCases({ + supertest: supertestWithoutAuth, + auth: { + user: noKibanaPrivileges, + space: null, + }, + expectedHttpCode: 403, + }); + }); + + it('should return a 404 when attempting to access a space', async () => { + await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, superUserDefaultSpaceAuth); + + await findCases({ + supertest: supertestWithoutAuth, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + + it('should return the correct cases when trying to exploit RBAC through the search query parameter', async () => { + await Promise.all([ + // super user creates a case with owner securitySolutionFixture + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, superUserDefaultSpaceAuth), + // super user creates a case with owner observabilityFixture + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserDefaultSpaceAuth + ), + ]); + + const res = await findCases({ + supertest: supertestWithoutAuth, + query: { + search: 'securitySolutionFixture observabilityFixture', + searchFields: 'owner', + }, + auth: secOnlyDefaultSpaceAuth, + }); + + ensureSavedObjectIsAuthorized(res.cases, 1, ['securitySolutionFixture']); + }); + + // This test is to prevent a future developer to add the filter attribute without taking into consideration + // the authorizationFilter produced by the cases authorization class + it('should NOT allow to pass a filter query parameter', async () => { + await supertest + .get( + `${CASES_URL}/_find?sortOrder=asc&filter=cases.attributes.owner:"observabilityFixture"` + ) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + + // This test ensures that the user is not allowed to define the namespaces query param + // so she cannot search across spaces + it('should NOT allow to pass a namespaces query parameter', async () => { + await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&namespaces[0]=*`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + + await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&namespaces=*`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + + it('should NOT allow to pass a non supported query parameter', async () => { + await supertest + .get(`${CASES_URL}/_find?notExists=papa`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + + it('should respect the owner filter when having permissions', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + obsSecDefaultSpaceAuth + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyDefaultSpaceAuth + ), + ]); + + const res = await findCases({ + supertest: supertestWithoutAuth, + query: { + owner: 'securitySolutionFixture', + searchFields: 'owner', + }, + auth: obsSecDefaultSpaceAuth, + }); + + ensureSavedObjectIsAuthorized(res.cases, 1, ['securitySolutionFixture']); + }); + + it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + obsSecDefaultSpaceAuth + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsSecDefaultSpaceAuth + ), + ]); + + // User with permissions only to security solution request cases from observability + const res = await findCases({ + supertest: supertestWithoutAuth, + query: { + owner: ['securitySolutionFixture', 'observabilityFixture'], + }, + auth: secOnlyDefaultSpaceAuth, + }); + + // Only security solution cases are being returned + ensureSavedObjectIsAuthorized(res.cases, 1, ['securitySolutionFixture']); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/cases/get_case.ts b/x-pack/test/case_api_integration/security_only/tests/common/cases/get_case.ts new file mode 100644 index 0000000000000..3bdb4c5ed310e --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/cases/get_case.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { AttributesTypeUser } from '../../../../../../plugins/cases/common/api'; +import { postCommentUserReq, getPostCaseRequest } from '../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + createCase, + getCase, + createComment, + removeServerGeneratedPropertiesFromSavedObject, +} from '../../../../common/lib/utils'; +import { + secOnlySpacesAll, + obsOnlySpacesAll, + globalRead, + superUser, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + obsSecSpacesAll, +} from '../../../../common/lib/authentication/users'; +import { getUserInfo } from '../../../../common/lib/authentication'; +import { secOnlyDefaultSpaceAuth, superUserDefaultSpaceAuth } from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('get_case', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('should get a case', async () => { + const newCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + for (const user of [ + globalRead, + superUser, + secOnlySpacesAll, + secOnlyReadSpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + ]) { + const theCase = await getCase({ + supertest: supertestWithoutAuth, + caseId: newCase.id, + auth: { user, space: null }, + }); + + expect(theCase.owner).to.eql('securitySolutionFixture'); + } + }); + + it('should get a case with comments', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + secOnlyDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + expectedHttpCode: 200, + auth: secOnlyDefaultSpaceAuth, + }); + + const theCase = await getCase({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + includeComments: true, + auth: secOnlyDefaultSpaceAuth, + }); + + const comment = removeServerGeneratedPropertiesFromSavedObject( + theCase.comments![0] as AttributesTypeUser + ); + + expect(theCase.comments?.length).to.eql(1); + expect(comment).to.eql({ + type: postCommentUserReq.type, + comment: postCommentUserReq.comment, + associationType: 'case', + created_by: getUserInfo(secOnlySpacesAll), + pushed_at: null, + pushed_by: null, + updated_by: null, + owner: 'securitySolutionFixture', + }); + }); + + it('should not get a case when the user does not have access to owner', async () => { + const newCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + for (const user of [noKibanaPrivileges, obsOnlySpacesAll, obsOnlyReadSpacesAll]) { + await getCase({ + supertest: supertestWithoutAuth, + caseId: newCase.id, + expectedHttpCode: 403, + auth: { user, space: null }, + }); + } + }); + + it('should return a 404 when attempting to access a space', async () => { + const newCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await getCase({ + supertest: supertestWithoutAuth, + caseId: newCase.id, + expectedHttpCode: 404, + auth: { user: secOnlySpacesAll, space: 'space1' }, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/cases/patch_cases.ts b/x-pack/test/case_api_integration/security_only/tests/common/cases/patch_cases.ts new file mode 100644 index 0000000000000..bfab3fce7adbe --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/cases/patch_cases.ts @@ -0,0 +1,243 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { getPostCaseRequest, postCaseReq } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + createCase, + updateCase, + findCases, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +import { + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, + superUser, +} from '../../../../common/lib/authentication/users'; +import { + obsOnlyDefaultSpaceAuth, + secOnlyDefaultSpaceAuth, + superUserDefaultSpaceAuth, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('patch_cases', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should update a case when the user has the correct permissions', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + postCaseReq, + 200, + secOnlyDefaultSpaceAuth + ); + + const patchedCases = await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + auth: secOnlyDefaultSpaceAuth, + }); + + expect(patchedCases[0].owner).to.eql('securitySolutionFixture'); + }); + + it('should update multiple cases when the user has the correct permissions', async () => { + const [case1, case2, case3] = await Promise.all([ + createCase(supertestWithoutAuth, postCaseReq, 200, superUserDefaultSpaceAuth), + createCase(supertestWithoutAuth, postCaseReq, 200, superUserDefaultSpaceAuth), + createCase(supertestWithoutAuth, postCaseReq, 200, superUserDefaultSpaceAuth), + ]); + + const patchedCases = await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: case1.id, + version: case1.version, + title: 'new title', + }, + { + id: case2.id, + version: case2.version, + title: 'new title', + }, + { + id: case3.id, + version: case3.version, + title: 'new title', + }, + ], + }, + auth: secOnlyDefaultSpaceAuth, + }); + + expect(patchedCases[0].owner).to.eql('securitySolutionFixture'); + expect(patchedCases[1].owner).to.eql('securitySolutionFixture'); + expect(patchedCases[2].owner).to.eql('securitySolutionFixture'); + }); + + it('should not update a case when the user does not have the correct ownership', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyDefaultSpaceAuth + ); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + auth: secOnlyDefaultSpaceAuth, + expectedHttpCode: 403, + }); + }); + + it('should not update any cases when the user does not have the correct ownership', async () => { + const [case1, case2, case3] = await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserDefaultSpaceAuth + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserDefaultSpaceAuth + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserDefaultSpaceAuth + ), + ]); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: case1.id, + version: case1.version, + title: 'new title', + }, + { + id: case2.id, + version: case2.version, + title: 'new title', + }, + { + id: case3.id, + version: case3.version, + title: 'new title', + }, + ], + }, + auth: secOnlyDefaultSpaceAuth, + expectedHttpCode: 403, + }); + + const resp = await findCases({ supertest, auth: getAuthWithSuperUser(null) }); + expect(resp.cases.length).to.eql(3); + // the update should have failed and none of the title should have been changed + expect(resp.cases[0].title).to.eql(postCaseReq.title); + expect(resp.cases[1].title).to.eql(postCaseReq.title); + expect(resp.cases[2].title).to.eql(postCaseReq.title); + }); + + for (const user of [ + globalRead, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + ]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT update a case`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + auth: { user, space: null }, + expectedHttpCode: 403, + }); + }); + } + + it('should return a 404 when attempting to access a space', async () => { + const postedCase = await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { + user: superUser, + space: null, + }); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/cases/post_case.ts b/x-pack/test/case_api_integration/security_only/tests/common/cases/post_case.ts new file mode 100644 index 0000000000000..28043d7155e4a --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/cases/post_case.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { getPostCaseRequest } from '../../../../common/lib/mock'; +import { deleteCasesByESQuery, createCase } from '../../../../common/lib/utils'; +import { + secOnlySpacesAll, + secOnlyReadSpacesAll, + globalRead, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, +} from '../../../../common/lib/authentication/users'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { secOnlyDefaultSpaceAuth } from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('post_case', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('User: security solution only - should create a case', async () => { + const theCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + secOnlyDefaultSpaceAuth + ); + expect(theCase.owner).to.eql('securitySolutionFixture'); + }); + + it('User: security solution only - should NOT create a case of different owner', async () => { + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 403, + secOnlyDefaultSpaceAuth + ); + }); + + for (const user of [ + globalRead, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + ]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT create a case`, async () => { + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 403, + { user, space: null } + ); + }); + } + + it('should return a 404 when attempting to access a space', async () => { + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 404, + { + user: secOnlySpacesAll, + space: 'space1', + } + ); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/cases/reporters/get_reporters.ts b/x-pack/test/case_api_integration/security_only/tests/common/cases/reporters/get_reporters.ts new file mode 100644 index 0000000000000..4c72dafed053b --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/cases/reporters/get_reporters.ts @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { getPostCaseRequest } from '../../../../../common/lib/mock'; +import { createCase, deleteCasesByESQuery, getReporters } from '../../../../../common/lib/utils'; +import { + secOnlySpacesAll, + obsOnlySpacesAll, + globalRead, + superUser, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + obsSecSpacesAll, +} from '../../../../../common/lib/authentication/users'; +import { getUserInfo } from '../../../../../common/lib/authentication'; +import { + secOnlyDefaultSpaceAuth, + obsOnlyDefaultSpaceAuth, + superUserDefaultSpaceAuth, + obsSecDefaultSpaceAuth, +} from '../../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('get_reporters', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('User: security solution only - should read the correct reporters', async () => { + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + secOnlyDefaultSpaceAuth + ); + + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyDefaultSpaceAuth + ); + + for (const scenario of [ + { + user: globalRead, + expectedReporters: [getUserInfo(secOnlySpacesAll), getUserInfo(obsOnlySpacesAll)], + }, + { + user: superUser, + expectedReporters: [getUserInfo(secOnlySpacesAll), getUserInfo(obsOnlySpacesAll)], + }, + { user: secOnlyReadSpacesAll, expectedReporters: [getUserInfo(secOnlySpacesAll)] }, + { user: obsOnlyReadSpacesAll, expectedReporters: [getUserInfo(obsOnlySpacesAll)] }, + { + user: obsSecReadSpacesAll, + expectedReporters: [getUserInfo(secOnlySpacesAll), getUserInfo(obsOnlySpacesAll)], + }, + ]) { + const reporters = await getReporters({ + supertest: supertestWithoutAuth, + expectedHttpCode: 200, + auth: { + user: scenario.user, + space: null, + }, + }); + + expect(reporters).to.eql(scenario.expectedReporters); + } + }); + + it(`User ${ + noKibanaPrivileges.username + } with role(s) ${noKibanaPrivileges.roles.join()} - should NOT get all reporters`, async () => { + // super user creates a case at the appropriate space + await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, superUserDefaultSpaceAuth); + + // user should not be able to get all reporters at the appropriate space + await getReporters({ + supertest: supertestWithoutAuth, + expectedHttpCode: 403, + auth: { user: noKibanaPrivileges, space: null }, + }); + }); + + it('should return a 404 when attempting to access a space', async () => { + await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, { + user: superUser, + space: null, + }); + + await getReporters({ + supertest: supertestWithoutAuth, + expectedHttpCode: 404, + auth: { user: obsSecSpacesAll, space: 'space1' }, + }); + }); + + it('should respect the owner filter when having permissions', async () => { + await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyDefaultSpaceAuth), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyDefaultSpaceAuth + ), + ]); + + const reporters = await getReporters({ + supertest: supertestWithoutAuth, + auth: obsSecDefaultSpaceAuth, + query: { owner: 'securitySolutionFixture' }, + }); + + expect(reporters).to.eql([getUserInfo(secOnlySpacesAll)]); + }); + + it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => { + await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyDefaultSpaceAuth), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyDefaultSpaceAuth + ), + ]); + + // User with permissions only to security solution request reporters from observability + const reporters = await getReporters({ + supertest: supertestWithoutAuth, + auth: secOnlyDefaultSpaceAuth, + query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, + }); + + // Only security solution reporters are being returned + expect(reporters).to.eql([getUserInfo(secOnlySpacesAll)]); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/cases/status/get_status.ts b/x-pack/test/case_api_integration/security_only/tests/common/cases/status/get_status.ts new file mode 100644 index 0000000000000..245c7d1fdbfc5 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/cases/status/get_status.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { CaseStatuses } from '../../../../../../../plugins/cases/common/api'; +import { getPostCaseRequest } from '../../../../../common/lib/mock'; +import { + createCase, + updateCase, + getAllCasesStatuses, + deleteAllCaseItems, +} from '../../../../../common/lib/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, + superUser, +} from '../../../../../common/lib/authentication/users'; +import { superUserDefaultSpaceAuth } from '../../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + + describe('get_status', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should return the correct status stats', async () => { + /** + * Owner: Sec + * open: 0, in-prog: 1, closed: 1 + * Owner: Obs + * open: 1, in-prog: 1 + */ + const [inProgressSec, closedSec, , inProgressObs] = await Promise.all([ + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, superUserDefaultSpaceAuth), + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, superUserDefaultSpaceAuth), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserDefaultSpaceAuth + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserDefaultSpaceAuth + ), + ]); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: inProgressSec.id, + version: inProgressSec.version, + status: CaseStatuses['in-progress'], + }, + { + id: closedSec.id, + version: closedSec.version, + status: CaseStatuses.closed, + }, + { + id: inProgressObs.id, + version: inProgressObs.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + auth: superUserDefaultSpaceAuth, + }); + + for (const scenario of [ + { user: globalRead, stats: { open: 1, inProgress: 2, closed: 1 } }, + { user: superUser, stats: { open: 1, inProgress: 2, closed: 1 } }, + { user: secOnlyReadSpacesAll, stats: { open: 0, inProgress: 1, closed: 1 } }, + { user: obsOnlyReadSpacesAll, stats: { open: 1, inProgress: 1, closed: 0 } }, + { user: obsSecReadSpacesAll, stats: { open: 1, inProgress: 2, closed: 1 } }, + { + user: obsSecReadSpacesAll, + stats: { open: 1, inProgress: 1, closed: 0 }, + owner: 'observabilityFixture', + }, + { + user: obsSecReadSpacesAll, + stats: { open: 1, inProgress: 2, closed: 1 }, + owner: ['observabilityFixture', 'securitySolutionFixture'], + }, + ]) { + const statuses = await getAllCasesStatuses({ + supertest: supertestWithoutAuth, + auth: { user: scenario.user, space: null }, + query: { + owner: scenario.owner, + }, + }); + + expect(statuses).to.eql({ + count_open_cases: scenario.stats.open, + count_closed_cases: scenario.stats.closed, + count_in_progress_cases: scenario.stats.inProgress, + }); + } + }); + + it(`should return a 403 when retrieving the statuses when the user ${ + noKibanaPrivileges.username + } with role(s) ${noKibanaPrivileges.roles.join()}`, async () => { + await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, superUserDefaultSpaceAuth); + + await getAllCasesStatuses({ + supertest: supertestWithoutAuth, + auth: { user: noKibanaPrivileges, space: null }, + expectedHttpCode: 403, + }); + }); + + it('should return a 404 when attempting to access a space', async () => { + await createCase(supertestWithoutAuth, getPostCaseRequest(), 200, superUserDefaultSpaceAuth); + + await getAllCasesStatuses({ + supertest: supertestWithoutAuth, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/cases/tags/get_tags.ts b/x-pack/test/case_api_integration/security_only/tests/common/cases/tags/get_tags.ts new file mode 100644 index 0000000000000..c05d956028752 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/cases/tags/get_tags.ts @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { deleteCasesByESQuery, createCase, getTags } from '../../../../../common/lib/utils'; +import { getPostCaseRequest } from '../../../../../common/lib/mock'; +import { + secOnlySpacesAll, + globalRead, + superUser, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, +} from '../../../../../common/lib/authentication/users'; +import { + secOnlyDefaultSpaceAuth, + obsOnlyDefaultSpaceAuth, + obsSecDefaultSpaceAuth, + superUserDefaultSpaceAuth, +} from '../../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('get_tags', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('should read the correct tags', async () => { + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', tags: ['sec'] }), + 200, + secOnlyDefaultSpaceAuth + ); + + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture', tags: ['obs'] }), + 200, + obsOnlyDefaultSpaceAuth + ); + + for (const scenario of [ + { + user: globalRead, + expectedTags: ['sec', 'obs'], + }, + { + user: superUser, + expectedTags: ['sec', 'obs'], + }, + { user: secOnlyReadSpacesAll, expectedTags: ['sec'] }, + { user: obsOnlyReadSpacesAll, expectedTags: ['obs'] }, + { + user: obsSecReadSpacesAll, + expectedTags: ['sec', 'obs'], + }, + ]) { + const tags = await getTags({ + supertest: supertestWithoutAuth, + expectedHttpCode: 200, + auth: { + user: scenario.user, + space: null, + }, + }); + + expect(tags).to.eql(scenario.expectedTags); + } + }); + + it(`User ${ + noKibanaPrivileges.username + } with role(s) ${noKibanaPrivileges.roles.join()} - should NOT get all tags`, async () => { + // super user creates a case at the appropriate space + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', tags: ['sec'] }), + 200, + superUserDefaultSpaceAuth + ); + + // user should not be able to get all tags at the appropriate space + await getTags({ + supertest: supertestWithoutAuth, + expectedHttpCode: 403, + auth: { user: noKibanaPrivileges, space: null }, + }); + }); + + it('should return a 404 when attempting to access a space', async () => { + // super user creates a case at the appropriate space + await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', tags: ['sec'] }), + 200, + superUserDefaultSpaceAuth + ); + + await getTags({ + supertest: supertestWithoutAuth, + expectedHttpCode: 404, + auth: { user: secOnlySpacesAll, space: 'space1' }, + }); + }); + + it('should respect the owner filter when having permissions', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', tags: ['sec'] }), + 200, + obsSecDefaultSpaceAuth + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture', tags: ['obs'] }), + 200, + obsSecDefaultSpaceAuth + ), + ]); + + const tags = await getTags({ + supertest: supertestWithoutAuth, + auth: obsSecDefaultSpaceAuth, + query: { owner: 'securitySolutionFixture' }, + }); + + expect(tags).to.eql(['sec']); + }); + + it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => { + await Promise.all([ + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture', tags: ['sec'] }), + 200, + obsSecDefaultSpaceAuth + ), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture', tags: ['obs'] }), + 200, + obsSecDefaultSpaceAuth + ), + ]); + + // User with permissions only to security solution request tags from observability + const tags = await getTags({ + supertest: supertestWithoutAuth, + auth: secOnlyDefaultSpaceAuth, + query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, + }); + + // Only security solution tags are being returned + expect(tags).to.eql(['sec']); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/comments/delete_comment.ts b/x-pack/test/case_api_integration/security_only/tests/common/comments/delete_comment.ts new file mode 100644 index 0000000000000..6a2ddeecdb272 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/comments/delete_comment.ts @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + createComment, + deleteComment, + deleteAllComments, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, +} from '../../../../common/lib/authentication/users'; +import { obsOnlyDefaultSpaceAuth, secOnlyDefaultSpaceAuth } from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const superUserNoSpaceAuth = getAuthWithSuperUser(null); + + describe('delete_comment', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should delete a comment from the appropriate owner', async () => { + const secCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + secOnlyDefaultSpaceAuth + ); + + const commentResp = await createComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + params: postCommentUserReq, + auth: secOnlyDefaultSpaceAuth, + }); + + await deleteComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + commentId: commentResp.comments![0].id, + auth: secOnlyDefaultSpaceAuth, + }); + }); + + it('should delete multiple comments from the appropriate owner', async () => { + const secCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + secOnlyDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + params: postCommentUserReq, + auth: secOnlyDefaultSpaceAuth, + }); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + params: postCommentUserReq, + auth: secOnlyDefaultSpaceAuth, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + auth: secOnlyDefaultSpaceAuth, + }); + }); + + it('should not delete a comment from a different owner', async () => { + const secCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + secOnlyDefaultSpaceAuth + ); + + const commentResp = await createComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + params: postCommentUserReq, + auth: secOnlyDefaultSpaceAuth, + }); + + await deleteComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + commentId: commentResp.comments![0].id, + auth: obsOnlyDefaultSpaceAuth, + expectedHttpCode: 403, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + auth: obsOnlyDefaultSpaceAuth, + expectedHttpCode: 403, + }); + }); + + for (const user of [ + globalRead, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + ]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT delete a comment`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserNoSpaceAuth + ); + + const commentResp = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: superUserNoSpaceAuth, + }); + + await deleteComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + commentId: commentResp.comments![0].id, + auth: { user, space: null }, + expectedHttpCode: 403, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: { user, space: null }, + expectedHttpCode: 403, + }); + }); + } + + it('should return a 404 when attempting to access a space', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserNoSpaceAuth + ); + + const commentResp = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: superUserNoSpaceAuth, + }); + + await deleteComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + commentId: commentResp.comments![0].id, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/comments/find_comments.ts b/x-pack/test/case_api_integration/security_only/tests/common/comments/find_comments.ts new file mode 100644 index 0000000000000..5239c616603a8 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/comments/find_comments.ts @@ -0,0 +1,278 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { CommentsResponse } from '../../../../../../plugins/cases/common/api'; +import { + getPostCaseRequest, + postCommentAlertReq, + postCommentUserReq, +} from '../../../../common/lib/mock'; +import { + createComment, + deleteAllCaseItems, + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + ensureSavedObjectIsAuthorized, + getSpaceUrlPrefix, + createCase, +} from '../../../../common/lib/utils'; + +import { + secOnlySpacesAll, + obsOnlyReadSpacesAll, + secOnlyReadSpacesAll, + noKibanaPrivileges, + superUser, + globalRead, + obsSecReadSpacesAll, +} from '../../../../common/lib/authentication/users'; +import { + obsOnlyDefaultSpaceAuth, + secOnlyDefaultSpaceAuth, + superUserDefaultSpaceAuth, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('find_comments', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return the correct comments', async () => { + const [secCase, obsCase] = await Promise.all([ + // Create case owned by the security solution user + createCase(supertestWithoutAuth, getPostCaseRequest(), 200, secOnlyDefaultSpaceAuth), + createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyDefaultSpaceAuth + ), + // Create case owned by the observability user + ]); + + await Promise.all([ + createComment({ + supertest: supertestWithoutAuth, + caseId: secCase.id, + params: postCommentUserReq, + auth: secOnlyDefaultSpaceAuth, + }), + createComment({ + supertest: supertestWithoutAuth, + caseId: obsCase.id, + params: { ...postCommentAlertReq, owner: 'observabilityFixture' }, + auth: obsOnlyDefaultSpaceAuth, + }), + ]); + + for (const scenario of [ + { + user: globalRead, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: secCase.id, + }, + { + user: globalRead, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: obsCase.id, + }, + { + user: superUser, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: secCase.id, + }, + { + user: superUser, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: obsCase.id, + }, + { + user: secOnlyReadSpacesAll, + numExpectedEntites: 1, + owners: ['securitySolutionFixture'], + caseID: secCase.id, + }, + { + user: obsOnlyReadSpacesAll, + numExpectedEntites: 1, + owners: ['observabilityFixture'], + caseID: obsCase.id, + }, + { + user: obsSecReadSpacesAll, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: secCase.id, + }, + { + user: obsSecReadSpacesAll, + numExpectedEntites: 1, + owners: ['securitySolutionFixture', 'observabilityFixture'], + caseID: obsCase.id, + }, + ]) { + const { body: caseComments }: { body: CommentsResponse } = await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(null)}${CASES_URL}/${scenario.caseID}/comments/_find`) + .auth(scenario.user.username, scenario.user.password) + .expect(200); + + ensureSavedObjectIsAuthorized( + caseComments.comments, + scenario.numExpectedEntites, + scenario.owners + ); + } + }); + + it(`User ${ + noKibanaPrivileges.username + } with role(s) ${noKibanaPrivileges.roles.join()} - should NOT read a comment`, async () => { + // super user creates a case and comment in the appropriate space + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + auth: { user: superUser, space: null }, + params: { ...postCommentUserReq, owner: 'securitySolutionFixture' }, + caseId: caseInfo.id, + }); + + // user should not be able to read comments + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix(null)}${CASES_URL}/${caseInfo.id}/comments/_find`) + .auth(noKibanaPrivileges.username, noKibanaPrivileges.password) + .expect(403); + }); + + it('should return a 404 when attempting to access a space', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + auth: superUserDefaultSpaceAuth, + params: { ...postCommentUserReq, owner: 'securitySolutionFixture' }, + caseId: caseInfo.id, + }); + + await supertestWithoutAuth + .get(`${getSpaceUrlPrefix('space1')}${CASES_URL}/${caseInfo.id}/comments/_find`) + .auth(secOnlySpacesAll.username, secOnlySpacesAll.password) + .expect(404); + }); + + it('should not return any comments when trying to exploit RBAC through the search query parameter', async () => { + const obsCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + auth: superUserDefaultSpaceAuth, + params: { ...postCommentUserReq, owner: 'observabilityFixture' }, + caseId: obsCase.id, + }); + + const { body: res }: { body: CommentsResponse } = await supertestWithoutAuth + .get( + `${getSpaceUrlPrefix(null)}${CASES_URL}/${ + obsCase.id + }/comments/_find?search=securitySolutionFixture+observabilityFixture` + ) + .auth(secOnlySpacesAll.username, secOnlySpacesAll.password) + .expect(200); + + // shouldn't find any comments since they were created under the observability ownership + ensureSavedObjectIsAuthorized(res.comments, 0, ['securitySolutionFixture']); + }); + + it('should not allow retrieving unauthorized comments using the filter field', async () => { + const obsCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + superUserDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + auth: superUserDefaultSpaceAuth, + params: { ...postCommentUserReq, owner: 'observabilityFixture' }, + caseId: obsCase.id, + }); + + const { body: res } = await supertestWithoutAuth + .get( + `${getSpaceUrlPrefix(null)}${CASES_URL}/${ + obsCase.id + }/comments/_find?filter=cases-comments.attributes.owner:"observabilityFixture"` + ) + .auth(secOnlySpacesAll.username, secOnlySpacesAll.password) + .expect(200); + expect(res.comments.length).to.be(0); + }); + + // This test ensures that the user is not allowed to define the namespaces query param + // so she cannot search across spaces + it('should NOT allow to pass a namespaces query parameter', async () => { + const obsCase = await createCase( + supertest, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200 + ); + + await createComment({ + supertest, + params: { ...postCommentUserReq, owner: 'observabilityFixture' }, + caseId: obsCase.id, + }); + + await supertest.get(`${CASES_URL}/${obsCase.id}/comments/_find?namespaces[0]=*`).expect(400); + + await supertest.get(`${CASES_URL}/${obsCase.id}/comments/_find?namespaces=*`).expect(400); + }); + + it('should NOT allow to pass a non supported query parameter', async () => { + await supertest.get(`${CASES_URL}/id/comments/_find?notExists=papa`).expect(400); + await supertest.get(`${CASES_URL}/id/comments/_find?owner=papa`).expect(400); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/comments/get_all_comments.ts b/x-pack/test/case_api_integration/security_only/tests/common/comments/get_all_comments.ts new file mode 100644 index 0000000000000..a0010ef19499f --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/comments/get_all_comments.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + createCase, + createComment, + getAllComments, +} from '../../../../common/lib/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnlySpacesAll, + obsOnlyReadSpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, + superUser, +} from '../../../../common/lib/authentication/users'; +import { superUserDefaultSpaceAuth } from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + + describe('get_all_comments', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should get all comments when the user has the correct permissions', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + for (const user of [ + globalRead, + superUser, + secOnlySpacesAll, + secOnlyReadSpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + ]) { + const comments = await getAllComments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + auth: { user, space: null }, + }); + + expect(comments.length).to.eql(2); + } + }); + + it('should not get comments when the user does not have correct permission', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + for (const scenario of [ + { user: noKibanaPrivileges, returnCode: 403 }, + { user: obsOnlySpacesAll, returnCode: 200 }, + { user: obsOnlyReadSpacesAll, returnCode: 200 }, + ]) { + const comments = await getAllComments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + auth: { user: scenario.user, space: null }, + expectedHttpCode: scenario.returnCode, + }); + + // only check the length if we get a 200 in response + if (scenario.returnCode === 200) { + expect(comments.length).to.be(0); + } + } + }); + + it('should return a 404 when attempting to access a space', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + await getAllComments({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/comments/get_comment.ts b/x-pack/test/case_api_integration/security_only/tests/common/comments/get_comment.ts new file mode 100644 index 0000000000000..79693d3e0a574 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/comments/get_comment.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { postCommentUserReq, getPostCaseRequest } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + createCase, + createComment, + getComment, +} from '../../../../common/lib/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnlySpacesAll, + obsOnlyReadSpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, + superUser, +} from '../../../../common/lib/authentication/users'; +import { superUserDefaultSpaceAuth } from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + + describe('get_comment', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should get a comment when the user has the correct permissions', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + const caseWithComment = await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + for (const user of [ + globalRead, + superUser, + secOnlySpacesAll, + secOnlyReadSpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + ]) { + await getComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + commentId: caseWithComment.comments![0].id, + auth: { user, space: null }, + }); + } + }); + + it('should not get comment when the user does not have correct permissions', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + const caseWithComment = await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + for (const user of [noKibanaPrivileges, obsOnlySpacesAll, obsOnlyReadSpacesAll]) { + await getComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + commentId: caseWithComment.comments![0].id, + auth: { user, space: null }, + expectedHttpCode: 403, + }); + } + }); + + it('should return a 404 when attempting to access a space', async () => { + const caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + const caseWithComment = await createComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + await getComment({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + commentId: caseWithComment.comments![0].id, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/comments/patch_comment.ts b/x-pack/test/case_api_integration/security_only/tests/common/comments/patch_comment.ts new file mode 100644 index 0000000000000..7a25ec4ec3981 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/comments/patch_comment.ts @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { AttributesTypeUser, CommentType } from '../../../../../../plugins/cases/common/api'; +import { defaultUser, postCommentUserReq, getPostCaseRequest } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + createComment, + updateComment, +} from '../../../../common/lib/utils'; +import { + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, +} from '../../../../common/lib/authentication/users'; +import { + obsOnlyDefaultSpaceAuth, + secOnlyDefaultSpaceAuth, + superUserDefaultSpaceAuth, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('patch_comment', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should update a comment that the user has permissions for', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + const updatedCase = await updateComment({ + supertest, + caseId: postedCase.id, + req: { + ...postCommentUserReq, + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + }, + auth: secOnlyDefaultSpaceAuth, + }); + + const userComment = updatedCase.comments![0] as AttributesTypeUser; + expect(userComment.comment).to.eql(newComment); + expect(userComment.type).to.eql(CommentType.user); + expect(updatedCase.updated_by).to.eql(defaultUser); + expect(userComment.owner).to.eql('securitySolutionFixture'); + }); + + it('should not update a comment that has a different owner thant he user has access to', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + const patchedCase = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + await updateComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + req: { + ...postCommentUserReq, + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + }, + auth: obsOnlyDefaultSpaceAuth, + expectedHttpCode: 403, + }); + }); + + for (const user of [ + globalRead, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + ]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT update a comment`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + const patchedCase = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + await updateComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + req: { + ...postCommentUserReq, + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + }, + auth: { user, space: null }, + expectedHttpCode: 403, + }); + }); + } + + it('should return a 404 when attempting to access a space', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + const patchedCase = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: superUserDefaultSpaceAuth, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + await updateComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + req: { + ...postCommentUserReq, + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + }, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/comments/post_comment.ts b/x-pack/test/case_api_integration/security_only/tests/common/comments/post_comment.ts new file mode 100644 index 0000000000000..500308305d131 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/comments/post_comment.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { postCommentUserReq, getPostCaseRequest } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + createComment, +} from '../../../../common/lib/utils'; + +import { + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, +} from '../../../../common/lib/authentication/users'; +import { + obsOnlyDefaultSpaceAuth, + secOnlyDefaultSpaceAuth, + superUserDefaultSpaceAuth, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + + describe('post_comment', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should create a comment when the user has the correct permissions for that owner', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: secOnlyDefaultSpaceAuth, + }); + }); + + it('should not create a comment when the user does not have permissions for that owner', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'observabilityFixture' }), + 200, + obsOnlyDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: { ...postCommentUserReq, owner: 'observabilityFixture' }, + auth: secOnlyDefaultSpaceAuth, + expectedHttpCode: 403, + }); + }); + + for (const user of [ + globalRead, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + ]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should not create a comment`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user, space: null }, + expectedHttpCode: 403, + }); + }); + } + + it('should return a 404 when attempting to access a space', async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + superUserDefaultSpaceAuth + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/configure/get_configure.ts b/x-pack/test/case_api_integration/security_only/tests/common/configure/get_configure.ts new file mode 100644 index 0000000000000..0a8b3ebd8981e --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/configure/get_configure.ts @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + deleteConfiguration, + getConfiguration, + createConfiguration, + getConfigurationRequest, + ensureSavedObjectIsAuthorized, +} from '../../../../common/lib/utils'; +import { + secOnlySpacesAll, + obsOnlyReadSpacesAll, + secOnlyReadSpacesAll, + noKibanaPrivileges, + superUser, + globalRead, + obsSecReadSpacesAll, +} from '../../../../common/lib/authentication/users'; +import { + obsOnlyDefaultSpaceAuth, + obsSecDefaultSpaceAuth, + secOnlyDefaultSpaceAuth, + superUserDefaultSpaceAuth, +} from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('get_configure', () => { + afterEach(async () => { + await deleteConfiguration(es); + }); + + it('should return the correct configuration', async () => { + await createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + secOnlyDefaultSpaceAuth + ); + + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 200, + obsOnlyDefaultSpaceAuth + ); + + for (const scenario of [ + { + user: globalRead, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + { + user: superUser, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + { + user: secOnlyReadSpacesAll, + numberOfExpectedCases: 1, + owners: ['securitySolutionFixture'], + }, + { + user: obsOnlyReadSpacesAll, + numberOfExpectedCases: 1, + owners: ['observabilityFixture'], + }, + { + user: obsSecReadSpacesAll, + numberOfExpectedCases: 2, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + ]) { + const configuration = await getConfiguration({ + supertest: supertestWithoutAuth, + query: { owner: scenario.owners }, + expectedHttpCode: 200, + auth: { + user: scenario.user, + space: null, + }, + }); + + ensureSavedObjectIsAuthorized( + configuration, + scenario.numberOfExpectedCases, + scenario.owners + ); + } + }); + + it(`User ${ + noKibanaPrivileges.username + } with role(s) ${noKibanaPrivileges.roles.join()} - should NOT read a case configuration`, async () => { + // super user creates a configuration at the appropriate space + await createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + superUserDefaultSpaceAuth + ); + + // user should not be able to read configurations at the appropriate space + await getConfiguration({ + supertest: supertestWithoutAuth, + expectedHttpCode: 403, + auth: { + user: noKibanaPrivileges, + space: null, + }, + }); + }); + + it('should return a 404 when attempting to access a space', async () => { + await createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await getConfiguration({ + supertest: supertestWithoutAuth, + expectedHttpCode: 404, + auth: { + user: secOnlySpacesAll, + space: 'space1', + }, + }); + }); + + it('should respect the owner filter when having permissions', async () => { + await Promise.all([ + createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + obsSecDefaultSpaceAuth + ), + createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 200, + obsSecDefaultSpaceAuth + ), + ]); + + const res = await getConfiguration({ + supertest: supertestWithoutAuth, + query: { owner: 'securitySolutionFixture' }, + auth: obsSecDefaultSpaceAuth, + }); + + ensureSavedObjectIsAuthorized(res, 1, ['securitySolutionFixture']); + }); + + it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => { + await Promise.all([ + createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + obsSecDefaultSpaceAuth + ), + createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 200, + obsSecDefaultSpaceAuth + ), + ]); + + // User with permissions only to security solution request cases from observability + const res = await getConfiguration({ + supertest: supertestWithoutAuth, + query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, + auth: secOnlyDefaultSpaceAuth, + }); + + // Only security solution cases are being returned + ensureSavedObjectIsAuthorized(res, 1, ['securitySolutionFixture']); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/configure/patch_configure.ts b/x-pack/test/case_api_integration/security_only/tests/common/configure/patch_configure.ts new file mode 100644 index 0000000000000..eb1fa01221ae8 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/configure/patch_configure.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; + +import { + getConfigurationRequest, + deleteConfiguration, + createConfiguration, + updateConfiguration, +} from '../../../../common/lib/utils'; +import { + secOnlySpacesAll, + obsOnlyReadSpacesAll, + secOnlyReadSpacesAll, + noKibanaPrivileges, + globalRead, + obsSecReadSpacesAll, +} from '../../../../common/lib/authentication/users'; +import { secOnlyDefaultSpaceAuth, superUserDefaultSpaceAuth } from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('patch_configure', () => { + const actionsRemover = new ActionsRemover(supertest); + + afterEach(async () => { + await deleteConfiguration(es); + await actionsRemover.removeAll(); + }); + + it('User: security solution only - should update a configuration', async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + secOnlyDefaultSpaceAuth + ); + + const newConfiguration = await updateConfiguration( + supertestWithoutAuth, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 200, + secOnlyDefaultSpaceAuth + ); + + expect(newConfiguration.owner).to.eql('securitySolutionFixture'); + }); + + it('User: security solution only - should NOT update a configuration of different owner', async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 200, + superUserDefaultSpaceAuth + ); + + await updateConfiguration( + supertestWithoutAuth, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 403, + secOnlyDefaultSpaceAuth + ); + }); + + for (const user of [ + globalRead, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + ]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT update a configuration`, async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await updateConfiguration( + supertestWithoutAuth, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 403, + { + user, + space: null, + } + ); + }); + } + + it('should return a 404 when attempting to access a space', async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'securitySolutionFixture' }, + 200, + superUserDefaultSpaceAuth + ); + + await updateConfiguration( + supertestWithoutAuth, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 404, + { + user: secOnlySpacesAll, + space: 'space1', + } + ); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/configure/post_configure.ts b/x-pack/test/case_api_integration/security_only/tests/common/configure/post_configure.ts new file mode 100644 index 0000000000000..b3de6ec0487bb --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/configure/post_configure.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; + +import { + getConfigurationRequest, + deleteConfiguration, + createConfiguration, + getConfiguration, + ensureSavedObjectIsAuthorized, +} from '../../../../common/lib/utils'; + +import { + secOnlySpacesAll, + obsOnlyReadSpacesAll, + secOnlyReadSpacesAll, + noKibanaPrivileges, + globalRead, + obsSecReadSpacesAll, +} from '../../../../common/lib/authentication/users'; +import { secOnlyDefaultSpaceAuth, superUserDefaultSpaceAuth } from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('post_configure', () => { + const actionsRemover = new ActionsRemover(supertest); + + afterEach(async () => { + await deleteConfiguration(es); + await actionsRemover.removeAll(); + }); + + it('User: security solution only - should create a configuration', async () => { + const configuration = await createConfiguration( + supertestWithoutAuth, + getConfigurationRequest(), + 200, + secOnlyDefaultSpaceAuth + ); + + expect(configuration.owner).to.eql('securitySolutionFixture'); + }); + + it('User: security solution only - should NOT create a configuration of different owner', async () => { + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 403, + secOnlyDefaultSpaceAuth + ); + }); + + for (const user of [ + globalRead, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + ]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT create a configuration`, async () => { + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'securitySolutionFixture' }, + 403, + { + user, + space: null, + } + ); + }); + } + + it('should return a 404 when attempting to access a space', async () => { + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'securitySolutionFixture' }, + 404, + { + user: secOnlySpacesAll, + space: 'space1', + } + ); + }); + + it('it deletes the correct configurations', async () => { + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'securitySolutionFixture' }, + 200, + superUserDefaultSpaceAuth + ); + + /** + * This API call should not delete the previously created configuration + * as it belongs to a different owner + */ + await createConfiguration( + supertestWithoutAuth, + { ...getConfigurationRequest(), owner: 'observabilityFixture' }, + 200, + superUserDefaultSpaceAuth + ); + + const configuration = await getConfiguration({ + supertest: supertestWithoutAuth, + query: { owner: ['securitySolutionFixture', 'observabilityFixture'] }, + auth: superUserDefaultSpaceAuth, + }); + + /** + * This ensures that both configuration are returned as expected + * and neither of has been deleted + */ + ensureSavedObjectIsAuthorized(configuration, 2, [ + 'securitySolutionFixture', + 'observabilityFixture', + ]); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/index.ts b/x-pack/test/case_api_integration/security_only/tests/common/index.ts new file mode 100644 index 0000000000000..7dd6dd4e22711 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('Common', function () { + loadTestFile(require.resolve('./comments/delete_comment')); + loadTestFile(require.resolve('./comments/find_comments')); + loadTestFile(require.resolve('./comments/get_comment')); + loadTestFile(require.resolve('./comments/get_all_comments')); + loadTestFile(require.resolve('./comments/patch_comment')); + loadTestFile(require.resolve('./comments/post_comment')); + loadTestFile(require.resolve('./alerts/get_cases')); + loadTestFile(require.resolve('./cases/delete_cases')); + loadTestFile(require.resolve('./cases/find_cases')); + loadTestFile(require.resolve('./cases/get_case')); + loadTestFile(require.resolve('./cases/patch_cases')); + loadTestFile(require.resolve('./cases/post_case')); + loadTestFile(require.resolve('./cases/reporters/get_reporters')); + loadTestFile(require.resolve('./cases/status/get_status')); + loadTestFile(require.resolve('./cases/tags/get_tags')); + loadTestFile(require.resolve('./user_actions/get_all_user_actions')); + loadTestFile(require.resolve('./configure/get_configure')); + loadTestFile(require.resolve('./configure/patch_configure')); + loadTestFile(require.resolve('./configure/post_configure')); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/common/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/security_only/tests/common/user_actions/get_all_user_actions.ts new file mode 100644 index 0000000000000..bd36ce1b0d9d6 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/common/user_actions/get_all_user_actions.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CaseResponse, CaseStatuses } from '../../../../../../plugins/cases/common/api'; +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + createCase, + updateCase, + getCaseUserActions, +} from '../../../../common/lib/utils'; +import { + globalRead, + noKibanaPrivileges, + obsSecSpacesAll, + obsSecReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, + superUser, +} from '../../../../common/lib/authentication/users'; +import { superUserDefaultSpaceAuth } from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + + describe('get_all_user_actions', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + let caseInfo: CaseResponse; + beforeEach(async () => { + caseInfo = await createCase( + supertestWithoutAuth, + getPostCaseRequest(), + 200, + superUserDefaultSpaceAuth + ); + + await updateCase({ + supertest: supertestWithoutAuth, + params: { + cases: [ + { + id: caseInfo.id, + version: caseInfo.version, + status: CaseStatuses.closed, + }, + ], + }, + auth: superUserDefaultSpaceAuth, + }); + }); + + it('should get the user actions for a case when the user has the correct permissions', async () => { + for (const user of [ + globalRead, + superUser, + secOnlySpacesAll, + secOnlyReadSpacesAll, + obsSecSpacesAll, + obsSecReadSpacesAll, + ]) { + const userActions = await getCaseUserActions({ + supertest: supertestWithoutAuth, + caseID: caseInfo.id, + auth: { user, space: null }, + }); + + expect(userActions.length).to.eql(2); + } + }); + + it(`should 403 when requesting the user actions of a case with user ${ + noKibanaPrivileges.username + } with role(s) ${noKibanaPrivileges.roles.join()}`, async () => { + await getCaseUserActions({ + supertest: supertestWithoutAuth, + caseID: caseInfo.id, + auth: { user: noKibanaPrivileges, space: null }, + expectedHttpCode: 403, + }); + }); + + it('should return a 404 when attempting to access a space', async () => { + await getCaseUserActions({ + supertest: supertestWithoutAuth, + caseID: caseInfo.id, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts new file mode 100644 index 0000000000000..6294400281b92 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/trial/cases/push_case.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; + +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { + pushCase, + deleteAllCaseItems, + createCaseWithConnector, +} from '../../../../common/lib/utils'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; +import { + globalRead, + noKibanaPrivileges, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + secOnlySpacesAll, + secOnlyReadSpacesAll, +} from '../../../../common/lib/authentication/users'; +import { secOnlyDefaultSpaceAuth } from '../../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const es = getService('es'); + + describe('push_case', () => { + const actionsRemover = new ActionsRemover(supertest); + + let servicenowSimulatorURL: string = ''; + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + + afterEach(async () => { + await deleteAllCaseItems(es); + await actionsRemover.removeAll(); + }); + + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should push a case that the user has permissions for', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + + await pushCase({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + connectorId: connector.id, + auth: secOnlyDefaultSpaceAuth, + }); + }); + + it('should not push a case that the user does not have permissions for', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + createCaseReq: getPostCaseRequest({ owner: 'observabilityFixture' }), + }); + + await pushCase({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + connectorId: connector.id, + auth: secOnlyDefaultSpaceAuth, + expectedHttpCode: 403, + }); + }); + + for (const user of [ + globalRead, + secOnlyReadSpacesAll, + obsOnlyReadSpacesAll, + obsSecReadSpacesAll, + noKibanaPrivileges, + ]) { + it(`User ${ + user.username + } with role(s) ${user.roles.join()} - should NOT push a case`, async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + + await pushCase({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + connectorId: connector.id, + auth: { user, space: null }, + expectedHttpCode: 403, + }); + }); + } + + it('should return a 404 when attempting to access a space', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + + await pushCase({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + connectorId: connector.id, + auth: { user: secOnlySpacesAll, space: 'space1' }, + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/tests/trial/index.ts b/x-pack/test/case_api_integration/security_only/tests/trial/index.ts new file mode 100644 index 0000000000000..86a44459a5837 --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/tests/trial/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { rolesDefaultSpace } from '../../../common/lib/authentication/roles'; +import { usersDefaultSpace } from '../../../common/lib/authentication/users'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { createUsersAndRoles, deleteUsersAndRoles } from '../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('cases security only enabled: trial', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + // since spaces are disabled this changes each role to have access to all available spaces (it'll just be the default one) + await createUsersAndRoles(getService, usersDefaultSpace, rolesDefaultSpace); + }); + + after(async () => { + await deleteUsersAndRoles(getService, usersDefaultSpace, rolesDefaultSpace); + }); + + // Trial + loadTestFile(require.resolve('./cases/push_case')); + + // Common + loadTestFile(require.resolve('../common')); + }); +}; diff --git a/x-pack/test/case_api_integration/security_only/utils.ts b/x-pack/test/case_api_integration/security_only/utils.ts new file mode 100644 index 0000000000000..7c5764c558bbe --- /dev/null +++ b/x-pack/test/case_api_integration/security_only/utils.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 { + obsOnlySpacesAll, + obsSecSpacesAll, + secOnlySpacesAll, +} from '../common/lib/authentication/users'; +import { getAuthWithSuperUser } from '../common/lib/utils'; + +export const secOnlyDefaultSpaceAuth = { user: secOnlySpacesAll, space: null }; +export const obsOnlyDefaultSpaceAuth = { user: obsOnlySpacesAll, space: null }; +export const obsSecDefaultSpaceAuth = { user: obsSecSpacesAll, space: null }; +export const superUserDefaultSpaceAuth = getAuthWithSuperUser(null); diff --git a/x-pack/test/case_api_integration/spaces_only/config.ts b/x-pack/test/case_api_integration/spaces_only/config.ts new file mode 100644 index 0000000000000..53cfdb6f9285d --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/config.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createTestConfig } from '../common/config'; + +// eslint-disable-next-line import/no-default-export +export default createTestConfig('spaces_only', { + disabledPlugins: ['security'], + license: 'trial', + ssl: false, + testFiles: [require.resolve('./tests/trial')], +}); diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts new file mode 100644 index 0000000000000..9587502fb642c --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/alerts/get_cases.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/mock'; +import { + createCase, + createComment, + getCaseIDsByAlert, + deleteAllCaseItems, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + const authSpace2 = getAuthWithSuperUser('space2'); + + describe('get_cases using alertID', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return all cases with the same alert ID attached to them in space1', async () => { + const [case1, case2, case3] = await Promise.all([ + createCase(supertest, getPostCaseRequest(), 200, authSpace1), + createCase(supertest, getPostCaseRequest(), 200, authSpace1), + createCase(supertest, getPostCaseRequest(), 200, authSpace1), + ]); + + await Promise.all([ + createComment({ + supertest, + caseId: case1.id, + params: postCommentAlertReq, + auth: authSpace1, + }), + createComment({ + supertest, + caseId: case2.id, + params: postCommentAlertReq, + auth: authSpace1, + }), + createComment({ + supertest, + caseId: case3.id, + params: postCommentAlertReq, + auth: authSpace1, + }), + ]); + + const caseIDsWithAlert = await getCaseIDsByAlert({ + supertest, + alertID: 'test-id', + auth: authSpace1, + }); + + expect(caseIDsWithAlert.length).to.eql(3); + expect(caseIDsWithAlert).to.contain(case1.id); + expect(caseIDsWithAlert).to.contain(case2.id); + expect(caseIDsWithAlert).to.contain(case3.id); + }); + + it('should return 1 case in space2 when 2 cases were created in space1 and 1 in space2', async () => { + const [case1, case2, case3] = await Promise.all([ + createCase(supertest, getPostCaseRequest(), 200, authSpace1), + createCase(supertest, getPostCaseRequest(), 200, authSpace1), + createCase(supertest, getPostCaseRequest(), 200, authSpace2), + ]); + + await Promise.all([ + createComment({ + supertest, + caseId: case1.id, + params: postCommentAlertReq, + auth: authSpace1, + }), + createComment({ + supertest, + caseId: case2.id, + params: postCommentAlertReq, + auth: authSpace1, + }), + createComment({ + supertest, + caseId: case3.id, + params: postCommentAlertReq, + auth: authSpace2, + }), + ]); + + const caseIDsWithAlert = await getCaseIDsByAlert({ + supertest, + alertID: 'test-id', + auth: authSpace2, + }); + + expect(caseIDsWithAlert.length).to.eql(1); + expect(caseIDsWithAlert).to.eql([case3.id]); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/cases/delete_cases.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/delete_cases.ts new file mode 100644 index 0000000000000..9de57a1b7abe2 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/delete_cases.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + createCase, + deleteCases, + getCase, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('delete_cases', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should delete a case in space1', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest(), 200, authSpace1); + const body = await deleteCases({ supertest, caseIDs: [postedCase.id], auth: authSpace1 }); + + await getCase({ supertest, caseId: postedCase.id, expectedHttpCode: 404, auth: authSpace1 }); + expect(body).to.eql({}); + }); + + it('should not delete a case in a different space', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest(), 200, authSpace1); + await deleteCases({ + supertest, + caseIDs: [postedCase.id], + auth: getAuthWithSuperUser('space2'), + expectedHttpCode: 404, + }); + + // the case should still be there + const caseInfo = await getCase({ supertest, caseId: postedCase.id, auth: authSpace1 }); + expect(caseInfo.id).to.eql(postedCase.id); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/cases/find_cases.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/find_cases.ts new file mode 100644 index 0000000000000..6513fe25b28e9 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/find_cases.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { postCaseReq, findCasesResp } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + findCases, + createCase, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('find_cases', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return 3 cases in space1', async () => { + const a = await createCase(supertest, postCaseReq, 200, authSpace1); + const b = await createCase(supertest, postCaseReq, 200, authSpace1); + const c = await createCase(supertest, postCaseReq, 200, authSpace1); + + const cases = await findCases({ supertest, auth: authSpace1 }); + + expect(cases).to.eql({ + ...findCasesResp, + total: 3, + cases: [a, b, c], + count_open_cases: 3, + }); + }); + + it('should return 1 case in space2 when 2 cases were created in space1 and 1 in space2', async () => { + const authSpace2 = getAuthWithSuperUser('space2'); + const [, , space2Case] = await Promise.all([ + createCase(supertest, postCaseReq, 200, authSpace1), + createCase(supertest, postCaseReq, 200, authSpace1), + createCase(supertest, postCaseReq, 200, authSpace2), + ]); + + const cases = await findCases({ supertest, auth: authSpace2 }); + + expect(cases).to.eql({ + ...findCasesResp, + total: 1, + cases: [space2Case], + count_open_cases: 1, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/cases/get_case.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/get_case.ts new file mode 100644 index 0000000000000..3ea6fac3772ed --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/get_case.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { postCaseResp, getPostCaseRequest, nullUser } from '../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + createCase, + getCase, + removeServerGeneratedPropertiesFromCase, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('get_case', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('should return a case in space1', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest(), 200, authSpace1); + const theCase = await getCase({ supertest, caseId: postedCase.id, auth: authSpace1 }); + + const data = removeServerGeneratedPropertiesFromCase(theCase); + expect(data).to.eql({ ...postCaseResp(), created_by: nullUser }); + }); + + it('should not return a case in the wrong space', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest(), 200, authSpace1); + await getCase({ + supertest, + caseId: postedCase.id, + auth: getAuthWithSuperUser('space2'), + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/cases/patch_cases.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/patch_cases.ts new file mode 100644 index 0000000000000..361358dc40604 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/patch_cases.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { nullUser, postCaseReq, postCaseResp } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + createCase, + updateCase, + removeServerGeneratedPropertiesFromCase, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('patch_cases', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should patch a case in space1', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + const patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + auth: authSpace1, + }); + + const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); + expect(data).to.eql({ + ...postCaseResp(), + title: 'new title', + updated_by: nullUser, + created_by: nullUser, + }); + }); + + it('should not patch a case in a different space', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: 'new title', + }, + ], + }, + expectedHttpCode: 404, + auth: getAuthWithSuperUser('space2'), + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/cases/post_case.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/post_case.ts new file mode 100644 index 0000000000000..1fc70b0f97f5d --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/post_case.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; +import { getPostCaseRequest, nullUser, postCaseResp } from '../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + createCase, + removeServerGeneratedPropertiesFromCase, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('post_case', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('should post a case in space1', async () => { + const postedCase = await createCase( + supertest, + getPostCaseRequest({ + connector: { + id: '123', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, + }), + 200, + authSpace1 + ); + const data = removeServerGeneratedPropertiesFromCase(postedCase); + + expect(data).to.eql({ + ...postCaseResp( + null, + getPostCaseRequest({ + connector: { + id: '123', + name: 'Jira', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, + }) + ), + created_by: nullUser, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/cases/reporters/get_reporters.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/reporters/get_reporters.ts new file mode 100644 index 0000000000000..d3c3176f4649f --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/reporters/get_reporters.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { getPostCaseRequest } from '../../../../../common/lib/mock'; +import { + createCase, + deleteCasesByESQuery, + getAuthWithSuperUser, + getReporters, +} from '../../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + const authSpace2 = getAuthWithSuperUser('space2'); + + describe('get_reporters', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('should not return reporters when security is disabled', async () => { + await Promise.all([ + createCase(supertest, getPostCaseRequest(), 200, authSpace2), + createCase(supertest, getPostCaseRequest(), 200, authSpace1), + ]); + + const reportersSpace1 = await getReporters({ supertest, auth: authSpace1 }); + const reportersSpace2 = await getReporters({ + supertest, + auth: authSpace2, + }); + + expect(reportersSpace1).to.eql([]); + expect(reportersSpace2).to.eql([]); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/cases/status/get_status.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/status/get_status.ts new file mode 100644 index 0000000000000..7f2a774c28f39 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/status/get_status.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { CaseStatuses } from '../../../../../../../plugins/cases/common/api'; +import { postCaseReq } from '../../../../../common/lib/mock'; +import { + createCase, + updateCase, + getAllCasesStatuses, + deleteAllCaseItems, + getAuthWithSuperUser, +} from '../../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + const authSpace2 = getAuthWithSuperUser('space2'); + + describe('get_status', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return case statuses in space1', async () => { + /** + * space1: + * open: 1 + * in progress: 1 + * closed: 0 + * space2: + * closed: 1 + */ + const [, inProgressCase, postedCase] = await Promise.all([ + createCase(supertest, postCaseReq, 200, authSpace1), + createCase(supertest, postCaseReq, 200, authSpace1), + createCase(supertest, postCaseReq, 200, authSpace2), + ]); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: inProgressCase.id, + version: inProgressCase.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + auth: authSpace1, + }); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: CaseStatuses.closed, + }, + ], + }, + auth: authSpace2, + }); + + const statusesSpace1 = await getAllCasesStatuses({ supertest, auth: authSpace1 }); + const statusesSpace2 = await getAllCasesStatuses({ supertest, auth: authSpace2 }); + + expect(statusesSpace1).to.eql({ + count_open_cases: 1, + count_closed_cases: 0, + count_in_progress_cases: 1, + }); + + expect(statusesSpace2).to.eql({ + count_open_cases: 0, + count_closed_cases: 1, + count_in_progress_cases: 0, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/cases/tags/get_tags.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/tags/get_tags.ts new file mode 100644 index 0000000000000..630628a13b6b9 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/cases/tags/get_tags.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { + deleteCasesByESQuery, + createCase, + getTags, + getAuthWithSuperUser, +} from '../../../../../common/lib/utils'; +import { getPostCaseRequest } from '../../../../../common/lib/mock'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + const authSpace2 = getAuthWithSuperUser('space2'); + + describe('get_tags', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + }); + + it('should return case tags in space1', async () => { + await createCase(supertest, getPostCaseRequest(), 200, authSpace1); + await createCase(supertest, getPostCaseRequest({ tags: ['unique'] }), 200, authSpace2); + + const tagsSpace1 = await getTags({ supertest, auth: authSpace1 }); + const tagsSpace2 = await getTags({ supertest, auth: authSpace2 }); + + expect(tagsSpace1).to.eql(['defacement']); + expect(tagsSpace2).to.eql(['unique']); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/comments/delete_comment.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/delete_comment.ts new file mode 100644 index 0000000000000..7e5abeb7edc2f --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/delete_comment.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + createComment, + deleteComment, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('delete_comment', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should delete a comment from space1', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + + const comment = await deleteComment({ + supertest, + caseId: postedCase.id, + commentId: patchedCase.comments![0].id, + auth: authSpace1, + }); + + expect(comment).to.eql({}); + }); + + it('should not delete a comment from a different space', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + + await deleteComment({ + supertest, + caseId: postedCase.id, + commentId: patchedCase.comments![0].id, + expectedHttpCode: 404, + auth: getAuthWithSuperUser('space2'), + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/comments/find_comments.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/find_comments.ts new file mode 100644 index 0000000000000..4df4c560413e8 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/find_comments.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { getPostCaseRequest, postCommentUserReq } from '../../../../common/lib/mock'; +import { + createComment, + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + getSpaceUrlPrefix, + createCase, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('find_comments', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should find all case comments in space1', async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest(), 200, authSpace1); + await createComment({ + supertest, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: authSpace1, + }); + + const patchedCase = await createComment({ + supertest, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: authSpace1, + }); + + const { body: caseComments } = await supertest + .get(`${getSpaceUrlPrefix(authSpace1.space)}${CASES_URL}/${caseInfo.id}/comments/_find`) + .expect(200); + + expect(caseComments.comments).to.eql(patchedCase.comments); + }); + + it('should not find any case comments in space2', async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest(), 200, authSpace1); + await createComment({ + supertest, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: authSpace1, + }); + + await createComment({ + supertest, + caseId: caseInfo.id, + params: postCommentUserReq, + auth: authSpace1, + }); + + const { body: caseComments } = await supertest + .get(`${getSpaceUrlPrefix('space2')}${CASES_URL}/${caseInfo.id}/comments/_find`) + .expect(200); + + expect(caseComments.comments.length).to.eql(0); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/comments/get_all_comments.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/get_all_comments.ts new file mode 100644 index 0000000000000..ea3766b733cdc --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/get_all_comments.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + createCase, + createComment, + getAllComments, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('get_all_comments', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should get multiple comments for a single case in space1', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + const comments = await getAllComments({ supertest, caseId: postedCase.id, auth: authSpace1 }); + + expect(comments.length).to.eql(2); + }); + + it('should not find any comments in space2', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + const comments = await getAllComments({ + supertest, + caseId: postedCase.id, + auth: getAuthWithSuperUser('space2'), + }); + + expect(comments.length).to.eql(0); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/comments/get_comment.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/get_comment.ts new file mode 100644 index 0000000000000..048700993087d --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/get_comment.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + createCase, + createComment, + getComment, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('get_comment', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should get a comment in space1', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + const comment = await getComment({ + supertest, + caseId: postedCase.id, + commentId: patchedCase.comments![0].id, + auth: authSpace1, + }); + + expect(comment).to.eql(patchedCase.comments![0]); + }); + + it('should not get a comment in space2 when it was created in space1', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + await getComment({ + supertest, + caseId: postedCase.id, + commentId: patchedCase.comments![0].id, + auth: getAuthWithSuperUser('space2'), + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/comments/patch_comment.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/patch_comment.ts new file mode 100644 index 0000000000000..452d05c9c2362 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/patch_comment.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { AttributesTypeUser, CommentType } from '../../../../../../plugins/cases/common/api'; +import { nullUser, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + createComment, + updateComment, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('patch_comment', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should patch a comment in space1', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + const updatedCase = await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + type: CommentType.user, + owner: 'securitySolutionFixture', + }, + auth: authSpace1, + }); + + const userComment = updatedCase.comments![0] as AttributesTypeUser; + expect(userComment.comment).to.eql(newComment); + expect(userComment.type).to.eql(CommentType.user); + expect(updatedCase.updated_by).to.eql(nullUser); + }); + + it('should not patch a comment in a different space', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + await updateComment({ + supertest, + caseId: postedCase.id, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: newComment, + type: CommentType.user, + owner: 'securitySolutionFixture', + }, + auth: getAuthWithSuperUser('space2'), + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/comments/post_comment.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/post_comment.ts new file mode 100644 index 0000000000000..45175e8dafb04 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/comments/post_comment.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { AttributesTypeUser } from '../../../../../../plugins/cases/common/api'; +import { nullUser, postCaseReq, postCommentUserReq } from '../../../../common/lib/mock'; +import { + deleteCasesByESQuery, + deleteCasesUserActions, + deleteComments, + createCase, + createComment, + removeServerGeneratedPropertiesFromSavedObject, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('post_comment', () => { + afterEach(async () => { + await deleteCasesByESQuery(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should post a comment in space1', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + const patchedCase = await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: authSpace1, + }); + const comment = removeServerGeneratedPropertiesFromSavedObject( + patchedCase.comments![0] as AttributesTypeUser + ); + + expect(comment).to.eql({ + type: postCommentUserReq.type, + comment: postCommentUserReq.comment, + associationType: 'case', + created_by: nullUser, + pushed_at: null, + pushed_by: null, + updated_by: null, + owner: 'securitySolutionFixture', + }); + + // updates the case correctly after adding a comment + expect(patchedCase.totalComment).to.eql(patchedCase.comments!.length); + expect(patchedCase.updated_by).to.eql(nullUser); + }); + + it('should not post a comment on a case in a different space', async () => { + const postedCase = await createCase(supertest, postCaseReq, 200, authSpace1); + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentUserReq, + auth: getAuthWithSuperUser('space2'), + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/configure/get_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/configure/get_configure.ts new file mode 100644 index 0000000000000..573b96d71af4a --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/configure/get_configure.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { nullUser } from '../../../../common/lib/mock'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + deleteConfiguration, + getConfiguration, + createConfiguration, + getConfigurationRequest, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('get_configure', () => { + afterEach(async () => { + await deleteConfiguration(es); + }); + + it('should return a configuration in space1', async () => { + await createConfiguration(supertest, getConfigurationRequest(), 200, authSpace1); + const configuration = await getConfiguration({ supertest, auth: authSpace1 }); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql(getConfigurationOutput(false, { created_by: nullUser })); + }); + + it('should not find a configuration when looking in a different space', async () => { + await createConfiguration(supertest, getConfigurationRequest(), 200, authSpace1); + const configuration = await getConfiguration({ + supertest, + auth: getAuthWithSuperUser('space2'), + }); + + expect(configuration).to.eql([]); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/configure/patch_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/configure/patch_configure.ts new file mode 100644 index 0000000000000..f61e8698c1191 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/configure/patch_configure.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + getConfigurationRequest, + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + deleteConfiguration, + createConfiguration, + updateConfiguration, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; +import { nullUser } from '../../../../common/lib/mock'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('patch_configure', () => { + afterEach(async () => { + await deleteConfiguration(es); + }); + + it('should patch a configuration in space1', async () => { + const configuration = await createConfiguration( + supertest, + getConfigurationRequest(), + 200, + authSpace1 + ); + const newConfiguration = await updateConfiguration( + supertest, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 200, + authSpace1 + ); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ + ...getConfigurationOutput(false, { created_by: nullUser, updated_by: nullUser }), + closure_type: 'close-by-pushing', + }); + }); + + it('should not patch a configuration in a different space', async () => { + const configuration = await createConfiguration( + supertest, + getConfigurationRequest(), + 200, + authSpace1 + ); + await updateConfiguration( + supertest, + configuration.id, + { + closure_type: 'close-by-pushing', + version: configuration.version, + }, + 404, + getAuthWithSuperUser('space2') + ); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/configure/post_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/configure/post_configure.ts new file mode 100644 index 0000000000000..161075616925c --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/configure/post_configure.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + getConfigurationRequest, + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + deleteConfiguration, + createConfiguration, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; +import { nullUser } from '../../../../common/lib/mock'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('post_configure', () => { + afterEach(async () => { + await deleteConfiguration(es); + }); + + it('should create a configuration in space1', async () => { + const configuration = await createConfiguration( + supertest, + getConfigurationRequest(), + 200, + authSpace1 + ); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration); + expect(data).to.eql(getConfigurationOutput(false, { created_by: nullUser })); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/index.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/index.ts new file mode 100644 index 0000000000000..251a545f10681 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('Common', function () { + loadTestFile(require.resolve('./alerts/get_cases')); + loadTestFile(require.resolve('./comments/delete_comment')); + loadTestFile(require.resolve('./comments/find_comments')); + loadTestFile(require.resolve('./comments/get_comment')); + loadTestFile(require.resolve('./comments/get_all_comments')); + loadTestFile(require.resolve('./comments/patch_comment')); + loadTestFile(require.resolve('./comments/post_comment')); + loadTestFile(require.resolve('./cases/delete_cases')); + loadTestFile(require.resolve('./cases/find_cases')); + loadTestFile(require.resolve('./cases/get_case')); + loadTestFile(require.resolve('./cases/patch_cases')); + loadTestFile(require.resolve('./cases/post_case')); + loadTestFile(require.resolve('./cases/reporters/get_reporters')); + loadTestFile(require.resolve('./cases/status/get_status')); + loadTestFile(require.resolve('./cases/tags/get_tags')); + loadTestFile(require.resolve('./user_actions/get_all_user_actions')); + loadTestFile(require.resolve('./configure/get_configure')); + loadTestFile(require.resolve('./configure/patch_configure')); + loadTestFile(require.resolve('./configure/post_configure')); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/common/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/spaces_only/tests/common/user_actions/get_all_user_actions.ts new file mode 100644 index 0000000000000..199e53ebd1bb5 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/common/user_actions/get_all_user_actions.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { + deleteAllCaseItems, + createCase, + getCaseUserActions, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('get_all_user_actions', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it(`should get user actions in space1`, async () => { + const postedCase = await createCase(supertest, getPostCaseRequest(), 200, authSpace1); + const body = await getCaseUserActions({ supertest, caseID: postedCase.id, auth: authSpace1 }); + + expect(body.length).to.eql(1); + }); + + it(`should not get user actions in the wrong space`, async () => { + const postedCase = await createCase(supertest, getPostCaseRequest(), 200, authSpace1); + const body = await getCaseUserActions({ + supertest, + caseID: postedCase.id, + auth: getAuthWithSuperUser('space2'), + }); + + expect(body.length).to.eql(0); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts new file mode 100644 index 0000000000000..28b7fe6095507 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/cases/push_case.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; + +import { nullUser } from '../../../../common/lib/mock'; +import { + pushCase, + deleteAllCaseItems, + createCaseWithConnector, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const es = getService('es'); + const authSpace1 = getAuthWithSuperUser(); + + describe('push_case', () => { + const actionsRemover = new ActionsRemover(supertest); + + let servicenowSimulatorURL: string = ''; + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + + afterEach(async () => { + await deleteAllCaseItems(es); + await actionsRemover.removeAll(); + }); + + it('should push a case in space1', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + auth: authSpace1, + }); + const theCase = await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: connector.id, + auth: authSpace1, + }); + + const { pushed_at, external_url, ...rest } = theCase.external_service!; + + expect(rest).to.eql({ + pushed_by: nullUser, + connector_id: connector.id, + connector_name: connector.name, + external_id: '123', + external_title: 'INC01', + }); + + // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins + expect( + external_url.includes( + 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' + ) + ).to.equal(true); + }); + + it('should not push a case in a different space', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + auth: authSpace1, + }); + await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: connector.id, + auth: getAuthWithSuperUser('space2'), + expectedHttpCode: 404, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts new file mode 100644 index 0000000000000..a142e6470ae93 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_configure.ts @@ -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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; + +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; +import { + getServiceNowConnector, + createConnector, + createConfiguration, + getConfiguration, + getConfigurationRequest, + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + getAuthWithSuperUser, +} from '../../../../common/lib/utils'; +import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; +import { nullUser } from '../../../../common/lib/mock'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const actionsRemover = new ActionsRemover(supertest); + const kibanaServer = getService('kibanaServer'); + const authSpace1 = getAuthWithSuperUser(); + + describe('get_configure', () => { + let servicenowSimulatorURL: string = ''; + + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + + afterEach(async () => { + await actionsRemover.removeAll(); + }); + + it('should return a configuration with a mapping from space1', async () => { + const connector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + auth: authSpace1, + }); + actionsRemover.add('space1', connector.id, 'action', 'actions'); + + await createConfiguration( + supertest, + getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + }), + 200, + authSpace1 + ); + + const configuration = await getConfiguration({ supertest, auth: authSpace1 }); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql( + getConfigurationOutput(false, { + mappings: [ + { + action_type: 'overwrite', + source: 'title', + target: 'short_description', + }, + { + action_type: 'overwrite', + source: 'description', + target: 'description', + }, + { + action_type: 'append', + source: 'comments', + target: 'work_notes', + }, + ], + connector: { + id: connector.id, + name: connector.name, + type: connector.connector_type_id, + fields: null, + }, + created_by: nullUser, + }) + ); + }); + + it('should not return a configuration with a mapping from a different space', async () => { + const connector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + auth: authSpace1, + }); + actionsRemover.add('space1', connector.id, 'action', 'actions'); + + await createConfiguration( + supertest, + getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + }), + 200, + authSpace1 + ); + + const configuration = await getConfiguration({ + supertest, + auth: getAuthWithSuperUser('space2'), + }); + + expect(configuration).to.eql([]); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts new file mode 100644 index 0000000000000..0301fa3a930cb --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/get_connectors.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; +import { + getServiceNowConnector, + getJiraConnector, + getResilientConnector, + createConnector, + getServiceNowSIRConnector, + getAuthWithSuperUser, + getCaseConnectors, + getActionsSpace, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const actionsRemover = new ActionsRemover(supertest); + const authSpace1 = getAuthWithSuperUser(); + const space = getActionsSpace(authSpace1.space); + + describe('get_connectors', () => { + afterEach(async () => { + await actionsRemover.removeAll(); + }); + + it('should return the correct connectors in space1', async () => { + const snConnector = await createConnector({ + supertest, + req: getServiceNowConnector(), + auth: authSpace1, + }); + const emailConnector = await createConnector({ + supertest, + req: { + name: 'An email action', + connector_type_id: '.email', + config: { + service: '__json', + from: 'bob@example.com', + }, + secrets: { + user: 'bob', + password: 'supersecret', + }, + }, + auth: authSpace1, + }); + const jiraConnector = await createConnector({ + supertest, + req: getJiraConnector(), + auth: authSpace1, + }); + const resilientConnector = await createConnector({ + supertest, + req: getResilientConnector(), + auth: authSpace1, + }); + const sir = await createConnector({ + supertest, + req: getServiceNowSIRConnector(), + auth: authSpace1, + }); + + actionsRemover.add(space, sir.id, 'action', 'actions'); + actionsRemover.add(space, snConnector.id, 'action', 'actions'); + actionsRemover.add(space, emailConnector.id, 'action', 'actions'); + actionsRemover.add(space, jiraConnector.id, 'action', 'actions'); + actionsRemover.add(space, resilientConnector.id, 'action', 'actions'); + + const connectors = await getCaseConnectors({ supertest, auth: authSpace1 }); + + expect(connectors).to.eql([ + { + id: jiraConnector.id, + actionTypeId: '.jira', + name: 'Jira Connector', + config: { + apiUrl: 'http://some.non.existent.com', + projectKey: 'pkey', + }, + isPreconfigured: false, + isMissingSecrets: false, + referencedByCount: 0, + }, + { + id: resilientConnector.id, + actionTypeId: '.resilient', + name: 'Resilient Connector', + config: { + apiUrl: 'http://some.non.existent.com', + orgId: 'pkey', + }, + isPreconfigured: false, + isMissingSecrets: false, + referencedByCount: 0, + }, + { + id: snConnector.id, + actionTypeId: '.servicenow', + name: 'ServiceNow Connector', + config: { + apiUrl: 'http://some.non.existent.com', + }, + isPreconfigured: false, + isMissingSecrets: false, + referencedByCount: 0, + }, + { + id: sir.id, + actionTypeId: '.servicenow-sir', + name: 'ServiceNow Connector', + config: { apiUrl: 'http://some.non.existent.com' }, + isPreconfigured: false, + isMissingSecrets: false, + referencedByCount: 0, + }, + ]); + }); + + it('should not return any connectors when looking in the wrong space', async () => { + const snConnector = await createConnector({ + supertest, + req: getServiceNowConnector(), + auth: authSpace1, + }); + const emailConnector = await createConnector({ + supertest, + req: { + name: 'An email action', + connector_type_id: '.email', + config: { + service: '__json', + from: 'bob@example.com', + }, + secrets: { + user: 'bob', + password: 'supersecret', + }, + }, + auth: authSpace1, + }); + const jiraConnector = await createConnector({ + supertest, + req: getJiraConnector(), + auth: authSpace1, + }); + const resilientConnector = await createConnector({ + supertest, + req: getResilientConnector(), + auth: authSpace1, + }); + const sir = await createConnector({ + supertest, + req: getServiceNowSIRConnector(), + auth: authSpace1, + }); + + actionsRemover.add(space, sir.id, 'action', 'actions'); + actionsRemover.add(space, snConnector.id, 'action', 'actions'); + actionsRemover.add(space, emailConnector.id, 'action', 'actions'); + actionsRemover.add(space, jiraConnector.id, 'action', 'actions'); + actionsRemover.add(space, resilientConnector.id, 'action', 'actions'); + + const connectors = await getCaseConnectors({ + supertest, + auth: getAuthWithSuperUser('space2'), + }); + + expect(connectors).to.eql([]); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/index.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/index.ts new file mode 100644 index 0000000000000..0c8c3931d1577 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile }: FtrProviderContext): void => { + describe('configuration tests', function () { + loadTestFile(require.resolve('./get_configure')); + loadTestFile(require.resolve('./get_connectors')); + loadTestFile(require.resolve('./patch_configure')); + loadTestFile(require.resolve('./post_configure')); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts new file mode 100644 index 0000000000000..14d0debe2ac17 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/patch_configure.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; + +import { + getConfigurationRequest, + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + deleteConfiguration, + createConfiguration, + updateConfiguration, + getServiceNowConnector, + createConnector, + getAuthWithSuperUser, + getActionsSpace, +} from '../../../../common/lib/utils'; +import { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; +import { nullUser } from '../../../../common/lib/mock'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const kibanaServer = getService('kibanaServer'); + const authSpace1 = getAuthWithSuperUser(); + const space = getActionsSpace(authSpace1.space); + + describe('patch_configure', () => { + const actionsRemover = new ActionsRemover(supertest); + let servicenowSimulatorURL: string = ''; + + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + + afterEach(async () => { + await deleteConfiguration(es); + await actionsRemover.removeAll(); + }); + + it('should patch a configuration connector and create mappings in space1', async () => { + const connector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + auth: authSpace1, + }); + + actionsRemover.add(space, connector.id, 'action', 'actions'); + + // Configuration is created with no connector so the mappings are empty + const configuration = await createConfiguration( + supertest, + getConfigurationRequest(), + 200, + authSpace1 + ); + + // the update request doesn't accept the owner field + const { owner, ...reqWithoutOwner } = getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + fields: null, + }); + + const newConfiguration = await updateConfiguration( + supertest, + configuration.id, + { + ...reqWithoutOwner, + version: configuration.version, + }, + 200, + authSpace1 + ); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ + ...getConfigurationOutput(true), + created_by: nullUser, + updated_by: nullUser, + connector: { + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + fields: null, + }, + mappings: [ + { + action_type: 'overwrite', + source: 'title', + target: 'short_description', + }, + { + action_type: 'overwrite', + source: 'description', + target: 'description', + }, + { + action_type: 'append', + source: 'comments', + target: 'work_notes', + }, + ], + }); + }); + + it('should not patch a configuration connector when it is in a different space', async () => { + const connector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + auth: authSpace1, + }); + + actionsRemover.add(space, connector.id, 'action', 'actions'); + + // Configuration is created with no connector so the mappings are empty + const configuration = await createConfiguration( + supertest, + getConfigurationRequest(), + 200, + authSpace1 + ); + + // the update request doesn't accept the owner field + const { owner, ...reqWithoutOwner } = getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + fields: null, + }); + + await updateConfiguration( + supertest, + configuration.id, + { + ...reqWithoutOwner, + version: configuration.version, + }, + 404, + getAuthWithSuperUser('space2') + ); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts new file mode 100644 index 0000000000000..7c5035193d465 --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/configure/post_configure.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 { ConnectorTypes } from '../../../../../../plugins/cases/common/api'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; + +import { + getConfigurationRequest, + removeServerGeneratedPropertiesFromSavedObject, + getConfigurationOutput, + deleteConfiguration, + createConfiguration, + createConnector, + getServiceNowConnector, + getAuthWithSuperUser, + getActionsSpace, +} from '../../../../common/lib/utils'; +import { nullUser } from '../../../../common/lib/mock'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + const kibanaServer = getService('kibanaServer'); + const authSpace1 = getAuthWithSuperUser(); + const space = getActionsSpace(authSpace1.space); + + describe('post_configure', () => { + const actionsRemover = new ActionsRemover(supertest); + let servicenowSimulatorURL: string = ''; + + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + + afterEach(async () => { + await deleteConfiguration(es); + await actionsRemover.removeAll(); + }); + + it('should create a configuration with a mapping in space1', async () => { + const connector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + auth: authSpace1, + }); + + actionsRemover.add(space, connector.id, 'action', 'actions'); + + const postRes = await createConfiguration( + supertest, + getConfigurationRequest({ + id: connector.id, + name: connector.name, + type: connector.connector_type_id as ConnectorTypes, + }), + 200, + authSpace1 + ); + + const data = removeServerGeneratedPropertiesFromSavedObject(postRes); + expect(data).to.eql( + getConfigurationOutput(false, { + created_by: nullUser, + mappings: [ + { + action_type: 'overwrite', + source: 'title', + target: 'short_description', + }, + { + action_type: 'overwrite', + source: 'description', + target: 'description', + }, + { + action_type: 'append', + source: 'comments', + target: 'work_notes', + }, + ], + connector: { + id: connector.id, + name: connector.name, + type: connector.connector_type_id, + fields: null, + }, + }) + ); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/spaces_only/tests/trial/index.ts b/x-pack/test/case_api_integration/spaces_only/tests/trial/index.ts new file mode 100644 index 0000000000000..346640aa6b9de --- /dev/null +++ b/x-pack/test/case_api_integration/spaces_only/tests/trial/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { createSpaces, deleteSpaces } from '../../../common/lib/authentication'; + +// eslint-disable-next-line import/no-default-export +export default ({ loadTestFile, getService }: FtrProviderContext): void => { + describe('cases spaces only enabled: trial', function () { + // Fastest ciGroup for the moment. + this.tags('ciGroup5'); + + before(async () => { + await createSpaces(getService); + }); + + after(async () => { + await deleteSpaces(getService); + }); + + loadTestFile(require.resolve('../common')); + + loadTestFile(require.resolve('./cases/push_case')); + loadTestFile(require.resolve('./configure')); + }); +};