From 50b2011dd3d0bd225b0c2074320487560554e970 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 19 Nov 2024 14:15:38 -0500 Subject: [PATCH] [Cases] [Security Solution] New cases subfeatures, add comments and reopen cases (#194898) ## Summary This pr adds 2 new sub feature permissions to the cases plugin in stack/security/observability, that behave as follows. The first is for controlling the ability to reopen cases. When Cases has the read permission, and the reopen permission is not enabled, users have permissions as before. When enabled, users can move cases from closed to open/in progress, but nothing else. If a user has all and this permission, they can do anything as before, if the option is unselected, they can change case properties, and change a case from open to anything, in progress to anything, but if the case is closed, are unable to reopen it. The 2nd permission is 'Add comment'. When enabled and the user has case read permissions, users can add comments, but not make any other changes to the case. When the user has read and this deselected, read functions as before. When a user has this permission and cases is all, this functions as all. When they have all but this permission is deselected, the user can do everything normally, except add cases comments. ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Michael Olorunnisola Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> (cherry picked from commit 0afae423443ba13c47a263c4cbc270ea09942148) --- .../project_roles/security/roles.yml | 24 +- .../serverless_resources/security_roles.json | 14 +- .../features/product_features.ts | 2 +- .../features/src/cases/index.ts | 23 +- .../features/src/cases/types.ts | 1 - .../src/cases/v1_features/kibana_features.ts | 98 +++++ .../{ => v1_features}/kibana_sub_features.ts | 11 +- .../features/src/cases/v1_features/types.ts | 14 + .../{ => v2_features}/kibana_features.ts | 11 +- .../cases/v2_features/kibana_sub_features.ts | 177 +++++++++ .../features/src/constants.ts | 9 + .../features/src/product_features_keys.ts | 2 + .../__snapshots__/cases.test.ts.snap | 14 +- .../feature_privilege_builder/cases.test.ts | 13 +- .../feature_privilege_builder/cases.ts | 10 +- .../cases/common/constants/application.ts | 2 + .../plugins/cases/common/constants/index.ts | 2 + x-pack/plugins/cases/common/index.ts | 4 + x-pack/plugins/cases/common/ui/types.ts | 6 + .../utils/__snapshots__/api_tags.test.ts.snap | 12 +- x-pack/plugins/cases/common/utils/api_tags.ts | 3 +- .../cases/common/utils/capabilities.test.tsx | 7 + .../cases/common/utils/capabilities.ts | 7 + .../client/helpers/can_use_cases.test.ts | 62 +-- .../public/client/helpers/can_use_cases.ts | 14 +- .../client/helpers/capabilities.test.ts | 18 + .../public/client/helpers/capabilities.ts | 17 +- .../common/lib/kibana/__mocks__/index.ts | 2 +- .../public/common/lib/kibana/hooks.test.tsx | 2 +- .../cases/public/common/lib/kibana/hooks.ts | 12 +- .../common/lib/kibana/kibana_react.mock.tsx | 4 +- .../cases/public/common/mock/permissions.ts | 63 ++- .../status/use_should_disable_status.test.tsx | 88 +++++ .../status/use_should_disable_status.tsx | 39 ++ .../actions/status/use_status_action.test.tsx | 46 ++- .../actions/status/use_status_action.tsx | 22 +- .../components/add_comment/index.test.tsx | 28 +- .../public/components/add_comment/index.tsx | 4 +- .../components/all_cases/use_actions.test.tsx | 94 +++++ .../components/all_cases/use_actions.tsx | 20 +- .../all_cases/use_bulk_actions.test.tsx | 73 +++- .../components/all_cases/use_bulk_actions.tsx | 11 +- .../components/all_cases/utility_bar.tsx | 4 +- .../cases/public/components/app/index.tsx | 2 +- .../app/use_available_owners.test.ts | 12 +- .../components/app/use_available_owners.ts | 8 +- .../components/case_action_bar/index.tsx | 10 +- .../status_context_menu.test.tsx | 84 +++- .../case_action_bar/status_context_menu.tsx | 26 +- .../public/components/cases_context/index.tsx | 4 + .../public/components/files/add_file.test.tsx | 14 +- .../public/components/files/add_file.tsx | 2 +- .../components/recent_cases/index.test.tsx | 4 +- .../public/components/user_actions/index.tsx | 8 +- .../use_user_permissions.test.tsx | 259 ++++++++++++ .../user_actions/use_user_permissions.tsx | 38 ++ .../public/containers/use_get_cases.test.tsx | 4 +- x-pack/plugins/cases/public/mocks.ts | 2 + .../__snapshots__/audit_logger.test.ts.snap | 84 ++++ .../__snapshots__/authorization.test.ts.snap | 150 +++++++ .../server/authorization/audit_logger.ts | 7 +- .../authorization/authorization.test.ts | 76 ++++ .../server/authorization/authorization.ts | 42 +- .../cases/server/authorization/index.ts | 10 +- .../cases/server/authorization/types.ts | 3 +- .../server/client/cases/bulk_update.test.ts | 133 ++++++- .../cases/server/client/cases/bulk_update.ts | 23 +- .../server/connectors/cases/index.test.ts | 4 + .../server/connectors/cases/utils.test.ts | 1 + .../cases/server/connectors/cases/utils.ts | 3 +- .../cases/server/features/constants.ts | 18 + x-pack/plugins/cases/server/features/index.ts | 15 + .../server/{features.ts => features/v1.ts} | 48 ++- x-pack/plugins/cases/server/features/v2.ts | 195 +++++++++ x-pack/plugins/cases/server/plugin.ts | 8 +- .../common/feature_kibana_privileges.ts | 21 + .../__snapshots__/oss_features.test.ts.snap | 12 + .../feature_privilege_iterator.test.ts | 52 +++ .../feature_privilege_iterator.ts | 8 + .../plugins/features/server/feature_schema.ts | 2 + .../register_alerts_table_configuration.tsx | 2 +- .../header/add_to_case_action.test.tsx | 2 + .../observability/common/index.ts | 2 + .../pages/alerts/components/alert_actions.tsx | 2 +- .../pages/cases/components/cases.stories.tsx | 4 + .../observability/server/features/cases_v1.ts | 151 +++++++ .../observability/server/features/cases_v2.ts | 181 +++++++++ .../observability/server/plugin.ts | 113 +----- .../observability_shared/common/index.ts | 2 +- .../public/utils/cases_permissions.ts | 4 + .../roles/elasticsearch_role.test.ts | 4 +- .../security_solution/common/constants.ts | 2 +- .../common/test/ess_roles.json | 6 +- .../actions/take_action/index.tsx | 4 +- .../public/cases_test_utils.ts | 14 + .../use_add_to_existing_case.tsx | 2 +- .../use_add_to_new_case.tsx | 2 +- .../public/common/links/links.test.tsx | 20 +- .../alert_context_menu.test.tsx | 2 + .../use_add_to_case_actions.tsx | 12 +- .../public/management/cypress/tasks/common.ts | 2 +- .../public/overview/pages/data_quality.tsx | 4 +- .../security_solution/public/plugin.tsx | 2 +- .../components/modal/header/index.test.tsx | 2 +- .../components/modal/header/index.tsx | 2 +- .../endpoint_operations_analyst.ts | 2 +- .../without_response_actions_role.ts | 2 +- .../lib/product_features_service/mocks.ts | 5 + .../product_features_service.test.ts | 5 +- .../product_features_service.ts | 19 + .../components/add_to_existing_case.test.tsx | 6 +- .../cases/components/add_to_new_case.test.tsx | 6 +- .../cases/hooks/use_case_permission.test.tsx | 6 +- .../cases/hooks/use_case_permission.ts | 2 +- .../apis/cases/common/roles.ts | 78 ++++ .../apis/cases/common/users.ts | 24 ++ .../api_integration/apis/cases/privileges.ts | 70 ++++ .../apis/features/features/features.ts | 12 +- .../apis/security/privileges.ts | 30 ++ .../apis/security/privileges_basic.ts | 33 ++ .../security_solution/cases_privileges.ts | 4 +- .../common/lib/api/case.ts | 37 +- .../common/lib/authentication/roles.ts | 200 ++++------ .../common/lib/authentication/users.ts | 24 ++ .../security_solution/server/plugin.ts | 46 +++ .../trial/create_comment_sub_privilege.ts | 370 ++++++++++++++++++ .../tests/trial/delete_sub_privilege.ts | 3 +- .../security_and_spaces/tests/trial/index.ts | 1 + .../functional/services/ml/security_common.ts | 4 +- .../services/observability/users.ts | 2 +- .../apps/cases/common/roles.ts | 6 +- .../plugins/cases/public/application.tsx | 2 + .../observability_security.ts | 4 +- .../observability/pages/alerts/add_to_case.ts | 4 +- .../observability/pages/cases/case_details.ts | 2 +- .../tests/features/deprecated_features.ts | 3 + .../e2e/investigations/timelines/export.cy.ts | 3 +- .../cypress/tasks/privileges.ts | 4 + .../common/suites/create.ts | 2 + .../common/suites/get.ts | 2 + .../common/suites/get_all.ts | 2 + .../spaces_only/telemetry/telemetry.ts | 3 + x-pack/test/tsconfig.json | 2 +- .../lib/security/default_http_headers.ts | 1 + .../project_controller_security_roles.yml | 2 + 145 files changed, 3541 insertions(+), 516 deletions(-) create mode 100644 x-pack/packages/security-solution/features/src/cases/v1_features/kibana_features.ts rename x-pack/packages/security-solution/features/src/cases/{ => v1_features}/kibana_sub_features.ts (85%) create mode 100644 x-pack/packages/security-solution/features/src/cases/v1_features/types.ts rename x-pack/packages/security-solution/features/src/cases/{ => v2_features}/kibana_features.ts (84%) create mode 100644 x-pack/packages/security-solution/features/src/cases/v2_features/kibana_sub_features.ts create mode 100644 x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.test.tsx create mode 100644 x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.tsx create mode 100644 x-pack/plugins/cases/public/components/user_actions/use_user_permissions.test.tsx create mode 100644 x-pack/plugins/cases/public/components/user_actions/use_user_permissions.tsx create mode 100644 x-pack/plugins/cases/server/authorization/__snapshots__/authorization.test.ts.snap create mode 100644 x-pack/plugins/cases/server/features/constants.ts create mode 100644 x-pack/plugins/cases/server/features/index.ts rename x-pack/plugins/cases/server/{features.ts => features/v1.ts} (67%) create mode 100644 x-pack/plugins/cases/server/features/v2.ts create mode 100644 x-pack/plugins/observability_solution/observability/server/features/cases_v1.ts create mode 100644 x-pack/plugins/observability_solution/observability/server/features/cases_v2.ts create mode 100644 x-pack/test/cases_api_integration/security_and_spaces/tests/trial/create_comment_sub_privilege.ts diff --git a/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml b/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml index 5c8446123a4fb..07016d0f9fd8d 100644 --- a/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml +++ b/packages/kbn-es/src/serverless_resources/project_roles/security/roles.yml @@ -46,7 +46,7 @@ viewer: - feature_siem.read - feature_siem.read_alerts - feature_siem.endpoint_list_read - - feature_securitySolutionCases.read + - feature_securitySolutionCasesV2.read - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -126,7 +126,7 @@ editor: - feature_siem.process_operations_all - feature_siem.actions_log_management_all # Response actions history - feature_siem.file_operations_all - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -175,7 +175,7 @@ t1_analyst: - feature_siem.read - feature_siem.read_alerts - feature_siem.endpoint_list_read - - feature_securitySolutionCases.read + - feature_securitySolutionCasesV2.read - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -230,7 +230,7 @@ t2_analyst: - feature_siem.read - feature_siem.read_alerts - feature_siem.endpoint_list_read - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -300,7 +300,7 @@ t3_analyst: - feature_siem.actions_log_management_all # Response actions history - feature_siem.file_operations_all - feature_siem.scan_operations_all - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -362,7 +362,7 @@ threat_intelligence_analyst: - feature_siem.all - feature_siem.endpoint_list_read - feature_siem.blocklist_all - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -430,7 +430,7 @@ rule_author: - feature_siem.host_isolation_exceptions_read - feature_siem.blocklist_all # Elastic Defend Policy Management - feature_siem.actions_log_management_read - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.read @@ -502,7 +502,7 @@ soc_manager: - feature_siem.file_operations_all - feature_siem.execute_operations_all - feature_siem.scan_operations_all - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all @@ -562,7 +562,7 @@ detections_admin: - feature_siem.all - feature_siem.read_alerts - feature_siem.crud_alerts - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all @@ -621,7 +621,7 @@ platform_engineer: - feature_siem.host_isolation_exceptions_all - feature_siem.blocklist_all # Elastic Defend Policy Management - feature_siem.actions_log_management_read - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all @@ -694,7 +694,7 @@ endpoint_operations_analyst: - feature_siem.file_operations_all - feature_siem.execute_operations_all - feature_siem.scan_operations_all - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all @@ -769,7 +769,7 @@ endpoint_policy_manager: - feature_siem.event_filters_all - feature_siem.host_isolation_exceptions_all - feature_siem.blocklist_all # Elastic Defend Policy Management - - feature_securitySolutionCases.all + - feature_securitySolutionCasesV2.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all diff --git a/packages/kbn-es/src/serverless_resources/security_roles.json b/packages/kbn-es/src/serverless_resources/security_roles.json index 75106ba041d60..424cb898a4f96 100644 --- a/packages/kbn-es/src/serverless_resources/security_roles.json +++ b/packages/kbn-es/src/serverless_resources/security_roles.json @@ -35,7 +35,7 @@ "siem": ["read", "read_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["read"], + "securitySolutionCasesV2": ["read"], "actions": ["read"], "builtInAlerts": ["read"] }, @@ -82,7 +82,7 @@ "siem": ["read", "read_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["read"], + "securitySolutionCasesV2": ["read"], "actions": ["read"], "builtInAlerts": ["read"] }, @@ -150,7 +150,7 @@ "actions_log_management_all", "file_operations_all" ], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], "actions": ["read"], @@ -210,7 +210,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "actions": ["read"], "builtInAlerts": ["all"] }, @@ -263,7 +263,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "actions": ["all"], "builtInAlerts": ["all"] }, @@ -311,7 +311,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "actions": ["read"], "builtInAlerts": ["all"], "dev_tools": ["all"] @@ -366,7 +366,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "actions": ["all"], "builtInAlerts": ["all"] }, diff --git a/x-pack/packages/security-solution/features/product_features.ts b/x-pack/packages/security-solution/features/product_features.ts index b2c524ff6de1d..67d61f21fae5e 100644 --- a/x-pack/packages/security-solution/features/product_features.ts +++ b/x-pack/packages/security-solution/features/product_features.ts @@ -6,6 +6,6 @@ */ export { getSecurityFeature } from './src/security'; -export { getCasesFeature } from './src/cases'; +export { getCasesFeature, getCasesV2Feature } from './src/cases'; export { getAssistantFeature } from './src/assistant'; export { getAttackDiscoveryFeature } from './src/attack_discovery'; diff --git a/x-pack/packages/security-solution/features/src/cases/index.ts b/x-pack/packages/security-solution/features/src/cases/index.ts index 1dcb33d9c3be3..17e5110538b37 100644 --- a/x-pack/packages/security-solution/features/src/cases/index.ts +++ b/x-pack/packages/security-solution/features/src/cases/index.ts @@ -6,10 +6,21 @@ */ import type { CasesSubFeatureId } from '../product_features_keys'; import type { ProductFeatureParams } from '../types'; -import { getCasesBaseKibanaFeature } from './kibana_features'; -import { getCasesBaseKibanaSubFeatureIds, getCasesSubFeaturesMap } from './kibana_sub_features'; +import { getCasesBaseKibanaFeature } from './v1_features/kibana_features'; +import { + getCasesBaseKibanaSubFeatureIds, + getCasesSubFeaturesMap, +} from './v1_features/kibana_sub_features'; import type { CasesFeatureParams } from './types'; +import { getCasesBaseKibanaFeatureV2 } from './v2_features/kibana_features'; +import { + getCasesBaseKibanaSubFeatureIdsV2, + getCasesSubFeaturesMapV2, +} from './v2_features/kibana_sub_features'; +/** + * @deprecated Use getCasesV2Feature instead + */ export const getCasesFeature = ( params: CasesFeatureParams ): ProductFeatureParams => ({ @@ -17,3 +28,11 @@ export const getCasesFeature = ( baseKibanaSubFeatureIds: getCasesBaseKibanaSubFeatureIds(), subFeaturesMap: getCasesSubFeaturesMap(params), }); + +export const getCasesV2Feature = ( + params: CasesFeatureParams +): ProductFeatureParams => ({ + baseKibanaFeature: getCasesBaseKibanaFeatureV2(params), + baseKibanaSubFeatureIds: getCasesBaseKibanaSubFeatureIdsV2(), + subFeaturesMap: getCasesSubFeaturesMapV2(params), +}); diff --git a/x-pack/packages/security-solution/features/src/cases/types.ts b/x-pack/packages/security-solution/features/src/cases/types.ts index a87a1d787d7c0..17fb10fdd64ee 100644 --- a/x-pack/packages/security-solution/features/src/cases/types.ts +++ b/x-pack/packages/security-solution/features/src/cases/types.ts @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import type { CasesUiCapabilities, CasesApiTags } from '@kbn/cases-plugin/common'; import type { ProductFeatureCasesKey, CasesSubFeatureId } from '../product_features_keys'; import type { ProductFeatureKibanaConfig } from '../types'; diff --git a/x-pack/packages/security-solution/features/src/cases/v1_features/kibana_features.ts b/x-pack/packages/security-solution/features/src/cases/v1_features/kibana_features.ts new file mode 100644 index 0000000000000..db442d894363a --- /dev/null +++ b/x-pack/packages/security-solution/features/src/cases/v1_features/kibana_features.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 { i18n } from '@kbn/i18n'; + +import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; +import { KibanaFeatureScope } from '@kbn/features-plugin/common'; +import type { BaseKibanaFeatureConfig } from '../../types'; +import { APP_ID, CASES_FEATURE_ID, CASES_FEATURE_ID_V2 } from '../../constants'; +import type { CasesFeatureParams } from '../types'; + +/** + * @deprecated Use getCasesBaseKibanaFeatureV2 instead + */ +export const getCasesBaseKibanaFeature = ({ + uiCapabilities, + apiTags, + savedObjects, +}: CasesFeatureParams): BaseKibanaFeatureConfig => { + return { + deprecated: { + notice: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionCase.deprecationMessage', + { + defaultMessage: + 'The {currentId} permissions are deprecated, please see {casesFeatureIdV2}.', + values: { + currentId: CASES_FEATURE_ID, + casesFeatureIdV2: CASES_FEATURE_ID_V2, + }, + } + ), + }, + id: CASES_FEATURE_ID, + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionCaseTitleDeprecated', + { + defaultMessage: 'Cases (Deprecated)', + } + ), + order: 1100, + category: DEFAULT_APP_CATEGORIES.security, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [CASES_FEATURE_ID, 'kibana'], + catalogue: [APP_ID], + cases: [APP_ID], + privileges: { + all: { + api: [...apiTags.all, ...apiTags.createComment], + app: [CASES_FEATURE_ID, 'kibana'], + catalogue: [APP_ID], + cases: { + create: [APP_ID], + read: [APP_ID], + update: [APP_ID], + push: [APP_ID], + createComment: [APP_ID], + reopenCase: [APP_ID], + }, + savedObject: { + all: [...savedObjects.files], + read: [...savedObjects.files], + }, + ui: uiCapabilities.all, + replacedBy: { + default: [{ feature: CASES_FEATURE_ID_V2, privileges: ['all'] }], + minimal: [ + { + feature: CASES_FEATURE_ID_V2, + privileges: ['minimal_all', 'create_comment', 'case_reopen'], + }, + ], + }, + }, + read: { + api: apiTags.read, + app: [CASES_FEATURE_ID, 'kibana'], + catalogue: [APP_ID], + cases: { + read: [APP_ID], + }, + savedObject: { + all: [], + read: [...savedObjects.files], + }, + ui: uiCapabilities.read, + replacedBy: { + default: [{ feature: CASES_FEATURE_ID_V2, privileges: ['read'] }], + minimal: [{ feature: CASES_FEATURE_ID_V2, privileges: ['minimal_read'] }], + }, + }, + }, + }; +}; diff --git a/x-pack/packages/security-solution/features/src/cases/kibana_sub_features.ts b/x-pack/packages/security-solution/features/src/cases/v1_features/kibana_sub_features.ts similarity index 85% rename from x-pack/packages/security-solution/features/src/cases/kibana_sub_features.ts rename to x-pack/packages/security-solution/features/src/cases/v1_features/kibana_sub_features.ts index 914b23687956b..ade0dbab2bfea 100644 --- a/x-pack/packages/security-solution/features/src/cases/kibana_sub_features.ts +++ b/x-pack/packages/security-solution/features/src/cases/v1_features/kibana_sub_features.ts @@ -7,9 +7,9 @@ import { i18n } from '@kbn/i18n'; import type { SubFeatureConfig } from '@kbn/features-plugin/common'; -import { CasesSubFeatureId } from '../product_features_keys'; -import { APP_ID } from '../constants'; -import type { CasesFeatureParams } from './types'; +import { CasesSubFeatureId } from '../../product_features_keys'; +import { APP_ID, CASES_FEATURE_ID_V2 } from '../../constants'; +import type { CasesFeatureParams } from '../types'; /** * Sub-features that will always be available for Security Cases @@ -21,7 +21,8 @@ export const getCasesBaseKibanaSubFeatureIds = (): CasesSubFeatureId[] => [ ]; /** - * Defines all the Security Assistant subFeatures available. + * @deprecated Use getCasesSubFeaturesMapV2 instead + * @description - Defines all the Security Solution Cases available. * The order of the subFeatures is the order they will be displayed */ export const getCasesSubFeaturesMap = ({ @@ -55,6 +56,7 @@ export const getCasesSubFeaturesMap = ({ delete: [APP_ID], }, ui: uiCapabilities.delete, + replacedBy: [{ feature: CASES_FEATURE_ID_V2, privileges: ['cases_delete'] }], }, ], }, @@ -89,6 +91,7 @@ export const getCasesSubFeaturesMap = ({ settings: [APP_ID], }, ui: uiCapabilities.settings, + replacedBy: [{ feature: CASES_FEATURE_ID_V2, privileges: ['cases_settings'] }], }, ], }, diff --git a/x-pack/packages/security-solution/features/src/cases/v1_features/types.ts b/x-pack/packages/security-solution/features/src/cases/v1_features/types.ts new file mode 100644 index 0000000000000..f17f83ddecce8 --- /dev/null +++ b/x-pack/packages/security-solution/features/src/cases/v1_features/types.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 type { ProductFeatureCasesKey, CasesSubFeatureId } from '../../product_features_keys'; +import type { ProductFeatureKibanaConfig } from '../../types'; + +export type DefaultCasesProductFeaturesConfig = Record< + ProductFeatureCasesKey, + ProductFeatureKibanaConfig +>; diff --git a/x-pack/packages/security-solution/features/src/cases/kibana_features.ts b/x-pack/packages/security-solution/features/src/cases/v2_features/kibana_features.ts similarity index 84% rename from x-pack/packages/security-solution/features/src/cases/kibana_features.ts rename to x-pack/packages/security-solution/features/src/cases/v2_features/kibana_features.ts index dd49a60328288..c0c025335d054 100644 --- a/x-pack/packages/security-solution/features/src/cases/kibana_features.ts +++ b/x-pack/packages/security-solution/features/src/cases/v2_features/kibana_features.ts @@ -9,17 +9,17 @@ import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; import { KibanaFeatureScope } from '@kbn/features-plugin/common'; -import type { BaseKibanaFeatureConfig } from '../types'; -import { APP_ID, CASES_FEATURE_ID } from '../constants'; -import type { CasesFeatureParams } from './types'; +import type { BaseKibanaFeatureConfig } from '../../types'; +import { APP_ID, CASES_FEATURE_ID_V2, CASES_FEATURE_ID } from '../../constants'; +import type { CasesFeatureParams } from '../types'; -export const getCasesBaseKibanaFeature = ({ +export const getCasesBaseKibanaFeatureV2 = ({ uiCapabilities, apiTags, savedObjects, }: CasesFeatureParams): BaseKibanaFeatureConfig => { return { - id: CASES_FEATURE_ID, + id: CASES_FEATURE_ID_V2, name: i18n.translate( 'securitySolutionPackages.features.featureRegistry.linkSecuritySolutionCaseTitle', { @@ -41,6 +41,7 @@ export const getCasesBaseKibanaFeature = ({ create: [APP_ID], read: [APP_ID], update: [APP_ID], + push: [APP_ID], }, savedObject: { all: [...savedObjects.files], diff --git a/x-pack/packages/security-solution/features/src/cases/v2_features/kibana_sub_features.ts b/x-pack/packages/security-solution/features/src/cases/v2_features/kibana_sub_features.ts new file mode 100644 index 0000000000000..59aeb866039d4 --- /dev/null +++ b/x-pack/packages/security-solution/features/src/cases/v2_features/kibana_sub_features.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { SubFeatureConfig } from '@kbn/features-plugin/common'; +import { CasesSubFeatureId } from '../../product_features_keys'; +import { APP_ID } from '../../constants'; +import type { CasesFeatureParams } from '../types'; + +/** + * Sub-features that will always be available for Security Cases + * regardless of the product type. + */ +export const getCasesBaseKibanaSubFeatureIdsV2 = (): CasesSubFeatureId[] => [ + CasesSubFeatureId.deleteCases, + CasesSubFeatureId.casesSettings, + CasesSubFeatureId.createComment, + CasesSubFeatureId.reopenCase, +]; + +/** + * Defines all the Security Solution Cases subFeatures available. + * The order of the subFeatures is the order they will be displayed + */ +export const getCasesSubFeaturesMapV2 = ({ + uiCapabilities, + apiTags, + savedObjects, +}: CasesFeatureParams) => { + const deleteCasesSubFeature: SubFeatureConfig = { + name: i18n.translate('securitySolutionPackages.features.featureRegistry.deleteSubFeatureName', { + defaultMessage: 'Delete', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: apiTags.delete, + id: 'cases_delete', + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.deleteSubFeatureDetails', + { + defaultMessage: 'Delete cases and comments', + } + ), + includeIn: 'all', + savedObject: { + all: [...savedObjects.files], + read: [...savedObjects.files], + }, + cases: { + delete: [APP_ID], + }, + ui: uiCapabilities.delete, + }, + ], + }, + ], + }; + + const casesSettingsCasesSubFeature: SubFeatureConfig = { + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.casesSettingsSubFeatureName', + { + defaultMessage: 'Case settings', + } + ), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cases_settings', + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.casesSettingsSubFeatureDetails', + { + defaultMessage: 'Edit case settings', + } + ), + includeIn: 'all', + savedObject: { + all: [...savedObjects.files], + read: [...savedObjects.files], + }, + cases: { + settings: [APP_ID], + }, + ui: uiCapabilities.settings, + }, + ], + }, + ], + }; + + /* The below sub features were newly added in v2 (8.17) */ + + const casesAddCommentsCasesSubFeature: SubFeatureConfig = { + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.addCommentsSubFeatureName', + { + defaultMessage: 'Create comments & attachments', + } + ), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: apiTags.createComment, + id: 'create_comment', + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.addCommentsSubFeatureDetails', + { + defaultMessage: 'Add comments to cases', + } + ), + includeIn: 'all', + savedObject: { + all: [...savedObjects.files], + read: [...savedObjects.files], + }, + cases: { + createComment: [APP_ID], + }, + ui: uiCapabilities.createComment, + }, + ], + }, + ], + }; + const casesreopenCaseSubFeature: SubFeatureConfig = { + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.reopenCaseSubFeatureName', + { + defaultMessage: 'Re-open', + } + ), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'case_reopen', + name: i18n.translate( + 'securitySolutionPackages.features.featureRegistry.reopenCaseSubFeatureDetails', + { + defaultMessage: 'Re-open closed cases', + } + ), + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + cases: { + reopenCase: [APP_ID], + }, + ui: uiCapabilities.reopenCase, + }, + ], + }, + ], + }; + + return new Map([ + [CasesSubFeatureId.deleteCases, deleteCasesSubFeature], + [CasesSubFeatureId.casesSettings, casesSettingsCasesSubFeature], + /* The below sub features were newly added in v2 (8.17) */ + [CasesSubFeatureId.createComment, casesAddCommentsCasesSubFeature], + [CasesSubFeatureId.reopenCase, casesreopenCaseSubFeature], + ]); +}; diff --git a/x-pack/packages/security-solution/features/src/constants.ts b/x-pack/packages/security-solution/features/src/constants.ts index 5027a7c8d393b..c6acab28c4860 100644 --- a/x-pack/packages/security-solution/features/src/constants.ts +++ b/x-pack/packages/security-solution/features/src/constants.ts @@ -9,7 +9,16 @@ export const APP_ID = 'securitySolution' as const; export const SERVER_APP_ID = 'siem' as const; +/** + * @deprecated deprecated in 8.17. Use CASE_FEATURE_ID_V2 instead + */ export const CASES_FEATURE_ID = 'securitySolutionCases' as const; + +// New version created in 8.17 to adopt the roles migration changes +export const CASES_FEATURE_ID_V2 = 'securitySolutionCasesV2' as const; + +export const SECURITY_SOLUTION_CASES_APP_ID = 'securitySolutionCases' as const; + export const ASSISTANT_FEATURE_ID = 'securitySolutionAssistant' as const; export const ATTACK_DISCOVERY_FEATURE_ID = 'securitySolutionAttackDiscovery' as const; diff --git a/x-pack/packages/security-solution/features/src/product_features_keys.ts b/x-pack/packages/security-solution/features/src/product_features_keys.ts index e72e669716c59..42a190b189234 100644 --- a/x-pack/packages/security-solution/features/src/product_features_keys.ts +++ b/x-pack/packages/security-solution/features/src/product_features_keys.ts @@ -148,6 +148,8 @@ export enum SecuritySubFeatureId { export enum CasesSubFeatureId { deleteCases = 'deleteCasesSubFeature', casesSettings = 'casesSettingsSubFeature', + createComment = 'createCommentSubFeature', + reopenCase = 'reopenCaseSubFeature', } /** Sub-features IDs for Security Assistant */ diff --git a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap index 1874a17515e19..2997187697c40 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap +++ b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/__snapshots__/cases.test.ts.snap @@ -4,7 +4,6 @@ exports[`cases feature_privilege_builder within feature grants all privileges un Array [ "cases:observability/pushCase", "cases:observability/createCase", - "cases:observability/createComment", "cases:observability/getCase", "cases:observability/getComment", "cases:observability/getTags", @@ -17,12 +16,19 @@ Array [ "cases:observability/deleteComment", "cases:observability/createConfiguration", "cases:observability/updateConfiguration", + "cases:observability/createComment", + "cases:observability/reopenCase", ] `; exports[`cases feature_privilege_builder within feature grants create privileges under feature with id securitySolution 1`] = ` Array [ "cases:securitySolution/createCase", +] +`; + +exports[`cases feature_privilege_builder within feature grants createComment privileges under feature with id securitySolution 1`] = ` +Array [ "cases:securitySolution/createComment", ] `; @@ -51,6 +57,12 @@ Array [ ] `; +exports[`cases feature_privilege_builder within feature grants reopenCase privileges under feature with id observability 1`] = ` +Array [ + "cases:observability/reopenCase", +] +`; + exports[`cases feature_privilege_builder within feature grants settings privileges under feature with id observability 1`] = ` Array [ "cases:observability/createConfiguration", diff --git a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.test.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.test.ts index ad0563ef7a827..eae3bbc942e34 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.test.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.test.ts @@ -48,6 +48,8 @@ describe(`cases`, () => { ['update', 'observability'], ['delete', 'securitySolution'], ['settings', 'observability'], + ['createComment', 'securitySolution'], + ['reopenCase', 'observability'], ])('grants %s privileges under feature with id %s', (operation, featureID) => { const actions = new Actions(); const casesFeaturePrivilege = new FeaturePrivilegeCasesBuilder(actions); @@ -89,6 +91,8 @@ describe(`cases`, () => { delete: ['security'], read: ['obs'], settings: ['security'], + createComment: ['security'], + reopenCase: ['security'], }, savedObject: { all: [], @@ -112,7 +116,6 @@ describe(`cases`, () => { Array [ "cases:security/pushCase", "cases:security/createCase", - "cases:security/createComment", "cases:security/getCase", "cases:security/getComment", "cases:security/getTags", @@ -125,6 +128,8 @@ describe(`cases`, () => { "cases:security/deleteComment", "cases:security/createConfiguration", "cases:security/updateConfiguration", + "cases:security/createComment", + "cases:security/reopenCase", "cases:obs/getCase", "cases:obs/getComment", "cases:obs/getTags", @@ -168,7 +173,6 @@ describe(`cases`, () => { Array [ "cases:security/pushCase", "cases:security/createCase", - "cases:security/createComment", "cases:security/getCase", "cases:security/getComment", "cases:security/getTags", @@ -181,9 +185,10 @@ describe(`cases`, () => { "cases:security/deleteComment", "cases:security/createConfiguration", "cases:security/updateConfiguration", + "cases:security/createComment", + "cases:security/reopenCase", "cases:other-security/pushCase", "cases:other-security/createCase", - "cases:other-security/createComment", "cases:other-security/getCase", "cases:other-security/getComment", "cases:other-security/getTags", @@ -196,6 +201,8 @@ describe(`cases`, () => { "cases:other-security/deleteComment", "cases:other-security/createConfiguration", "cases:other-security/updateConfiguration", + "cases:other-security/createComment", + "cases:other-security/reopenCase", "cases:obs/getCase", "cases:obs/getComment", "cases:obs/getTags", diff --git a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts index 7672e1920fd4b..3cf293b935b36 100644 --- a/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts +++ b/x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts @@ -22,7 +22,7 @@ export type CasesSupportedOperations = (typeof allOperations)[number]; */ const pushOperations = ['pushCase'] as const; -const createOperations = ['createCase', 'createComment'] as const; +const createOperations = ['createCase'] as const; const readOperations = [ 'getCase', 'getComment', @@ -31,9 +31,12 @@ const readOperations = [ 'getUserActions', 'findConfigurations', ] as const; +// Update operations do not currently include the ability to re-open a case const updateOperations = ['updateCase', 'updateComment'] as const; const deleteOperations = ['deleteCase', 'deleteComment'] as const; const settingsOperations = ['createConfiguration', 'updateConfiguration'] as const; +const createCommentOperations = ['createComment'] as const; +const reopenOperations = ['reopenCase'] as const; const allOperations = [ ...pushOperations, ...createOperations, @@ -41,6 +44,8 @@ const allOperations = [ ...updateOperations, ...deleteOperations, ...settingsOperations, + ...createCommentOperations, + ...reopenOperations, ] as const; export class FeaturePrivilegeCasesBuilder extends BaseFeaturePrivilegeBuilder { @@ -56,7 +61,6 @@ export class FeaturePrivilegeCasesBuilder extends BaseFeaturePrivilegeBuilder { operations.map((operation) => this.actions.cases.get(owner, operation)) ); }; - return uniq([ ...getCasesPrivilege(allOperations, privilegeDefinition.cases?.all), ...getCasesPrivilege(pushOperations, privilegeDefinition.cases?.push), @@ -65,6 +69,8 @@ export class FeaturePrivilegeCasesBuilder extends BaseFeaturePrivilegeBuilder { ...getCasesPrivilege(updateOperations, privilegeDefinition.cases?.update), ...getCasesPrivilege(deleteOperations, privilegeDefinition.cases?.delete), ...getCasesPrivilege(settingsOperations, privilegeDefinition.cases?.settings), + ...getCasesPrivilege(createCommentOperations, privilegeDefinition.cases?.createComment), + ...getCasesPrivilege(reopenOperations, privilegeDefinition.cases?.reopenCase), ]); } } diff --git a/x-pack/plugins/cases/common/constants/application.ts b/x-pack/plugins/cases/common/constants/application.ts index 4b43a17708ab6..01bbea157e7d2 100644 --- a/x-pack/plugins/cases/common/constants/application.ts +++ b/x-pack/plugins/cases/common/constants/application.ts @@ -12,7 +12,9 @@ import { CASE_VIEW_PAGE_TABS } from '../types'; */ export const APP_ID = 'cases' as const; +/** @deprecated Please use FEATURE_ID_V2 instead */ export const FEATURE_ID = 'generalCases' as const; +export const FEATURE_ID_V2 = 'generalCasesV2' as const; export const APP_OWNER = 'cases' as const; export const APP_PATH = '/app/management/insightsAndAlerting/cases' as const; export const CASES_CREATE_PATH = '/create' as const; diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index aa3855807cea2..1fee73f8608c8 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -174,6 +174,8 @@ export const DELETE_CASES_CAPABILITY = 'delete_cases' as const; export const PUSH_CASES_CAPABILITY = 'push_cases' as const; export const CASES_SETTINGS_CAPABILITY = 'cases_settings' as const; export const CASES_CONNECTORS_CAPABILITY = 'cases_connectors' as const; +export const CASES_REOPEN_CAPABILITY = 'case_reopen' as const; +export const CREATE_COMMENT_CAPABILITY = 'create_comment' as const; /** * Cases API Tags diff --git a/x-pack/plugins/cases/common/index.ts b/x-pack/plugins/cases/common/index.ts index ead81710c451d..8e3b2644ee01a 100644 --- a/x-pack/plugins/cases/common/index.ts +++ b/x-pack/plugins/cases/common/index.ts @@ -18,6 +18,7 @@ export type { CasesBulkGetResponse, CasePostRequest, + CasePatchRequest, GetRelatedCasesByAlertResponse, UserActionFindResponse, } from './types/api'; @@ -38,6 +39,7 @@ export { CaseSeverity } from './types/domain'; export { APP_ID, FEATURE_ID, + FEATURE_ID_V2, CASES_URL, SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER, @@ -55,6 +57,8 @@ export { CASES_CONNECTORS_CAPABILITY, GET_CONNECTORS_CONFIGURE_API_TAG, CASES_SETTINGS_CAPABILITY, + CREATE_COMMENT_CAPABILITY, + CASES_REOPEN_CAPABILITY, } from './constants'; export type { AttachmentAttributes } from './types/domain'; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 6d75b30dd119d..99c92e0dbb55b 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -11,6 +11,8 @@ import type { DELETE_CASES_CAPABILITY, READ_CASES_CAPABILITY, UPDATE_CASES_CAPABILITY, + CREATE_COMMENT_CAPABILITY, + CASES_REOPEN_CAPABILITY, } from '..'; import type { CASES_CONNECTORS_CAPABILITY, @@ -305,6 +307,8 @@ export interface CasesPermissions { push: boolean; connectors: boolean; settings: boolean; + reopenCase: boolean; + createComment: boolean; } export interface CasesCapabilities { @@ -315,4 +319,6 @@ export interface CasesCapabilities { [PUSH_CASES_CAPABILITY]: boolean; [CASES_CONNECTORS_CAPABILITY]: boolean; [CASES_SETTINGS_CAPABILITY]: boolean; + [CREATE_COMMENT_CAPABILITY]: boolean; + [CASES_REOPEN_CAPABILITY]: boolean; } diff --git a/x-pack/plugins/cases/common/utils/__snapshots__/api_tags.test.ts.snap b/x-pack/plugins/cases/common/utils/__snapshots__/api_tags.test.ts.snap index 9cca596cc84d8..10fdb6da9673a 100644 --- a/x-pack/plugins/cases/common/utils/__snapshots__/api_tags.test.ts.snap +++ b/x-pack/plugins/cases/common/utils/__snapshots__/api_tags.test.ts.snap @@ -6,9 +6,11 @@ Object { "casesSuggestUserProfiles", "bulkGetUserProfiles", "casesGetConnectorsConfigure", - "casesFilesCasesCreate", "casesFilesCasesRead", ], + "createComment": Array [ + "casesFilesCasesCreate", + ], "delete": Array [ "casesFilesCasesDelete", ], @@ -27,9 +29,11 @@ Object { "casesSuggestUserProfiles", "bulkGetUserProfiles", "casesGetConnectorsConfigure", - "observabilityFilesCasesCreate", "observabilityFilesCasesRead", ], + "createComment": Array [ + "observabilityFilesCasesCreate", + ], "delete": Array [ "observabilityFilesCasesDelete", ], @@ -48,9 +52,11 @@ Object { "casesSuggestUserProfiles", "bulkGetUserProfiles", "casesGetConnectorsConfigure", - "securitySolutionFilesCasesCreate", "securitySolutionFilesCasesRead", ], + "createComment": Array [ + "securitySolutionFilesCasesCreate", + ], "delete": Array [ "securitySolutionFilesCasesDelete", ], diff --git a/x-pack/plugins/cases/common/utils/api_tags.ts b/x-pack/plugins/cases/common/utils/api_tags.ts index 3fbad714e55f9..e4750540c5b5e 100644 --- a/x-pack/plugins/cases/common/utils/api_tags.ts +++ b/x-pack/plugins/cases/common/utils/api_tags.ts @@ -18,6 +18,7 @@ export interface CasesApiTags { all: readonly string[]; read: readonly string[]; delete: readonly string[]; + createComment: readonly string[]; } export const getApiTags = (owner: Owner): CasesApiTags => { @@ -30,7 +31,6 @@ export const getApiTags = (owner: Owner): CasesApiTags => { SUGGEST_USER_PROFILES_API_TAG, BULK_GET_USER_PROFILES_API_TAG, GET_CONNECTORS_CONFIGURE_API_TAG, - create, read, ] as const, read: [ @@ -40,5 +40,6 @@ export const getApiTags = (owner: Owner): CasesApiTags => { read, ] as const, delete: [deleteTag] as const, + createComment: [create] as const, }; }; diff --git a/x-pack/plugins/cases/common/utils/capabilities.test.tsx b/x-pack/plugins/cases/common/utils/capabilities.test.tsx index 07b82ea0d0e8f..11f74af8e02d8 100644 --- a/x-pack/plugins/cases/common/utils/capabilities.test.tsx +++ b/x-pack/plugins/cases/common/utils/capabilities.test.tsx @@ -17,6 +17,10 @@ describe('createUICapabilities', () => { "update_cases", "push_cases", "cases_connectors", + "cases_settings", + ], + "createComment": Array [ + "create_comment", ], "delete": Array [ "delete_cases", @@ -25,6 +29,9 @@ describe('createUICapabilities', () => { "read_cases", "cases_connectors", ], + "reopenCase": Array [ + "case_reopen", + ], "settings": Array [ "cases_settings", ], diff --git a/x-pack/plugins/cases/common/utils/capabilities.ts b/x-pack/plugins/cases/common/utils/capabilities.ts index 6b33dd8c8dceb..6897dc6bae774 100644 --- a/x-pack/plugins/cases/common/utils/capabilities.ts +++ b/x-pack/plugins/cases/common/utils/capabilities.ts @@ -13,6 +13,8 @@ import { READ_CASES_CAPABILITY, UPDATE_CASES_CAPABILITY, CASES_SETTINGS_CAPABILITY, + CASES_REOPEN_CAPABILITY, + CREATE_COMMENT_CAPABILITY, } from '../constants'; export interface CasesUiCapabilities { @@ -20,6 +22,8 @@ export interface CasesUiCapabilities { read: readonly string[]; delete: readonly string[]; settings: readonly string[]; + reopenCase: readonly string[]; + createComment: readonly string[]; } /** * Return the UI capabilities for each type of operation. These strings must match the values defined in the UI @@ -32,8 +36,11 @@ export const createUICapabilities = (): CasesUiCapabilities => ({ UPDATE_CASES_CAPABILITY, PUSH_CASES_CAPABILITY, CASES_CONNECTORS_CAPABILITY, + CASES_SETTINGS_CAPABILITY, ] as const, read: [READ_CASES_CAPABILITY, CASES_CONNECTORS_CAPABILITY] as const, delete: [DELETE_CASES_CAPABILITY] as const, settings: [CASES_SETTINGS_CAPABILITY] as const, + reopenCase: [CASES_REOPEN_CAPABILITY] as const, + createComment: [CREATE_COMMENT_CAPABILITY] as const, }); diff --git a/x-pack/plugins/cases/public/client/helpers/can_use_cases.test.ts b/x-pack/plugins/cases/public/client/helpers/can_use_cases.test.ts index 5b82919523f36..69eca9d064602 100644 --- a/x-pack/plugins/cases/public/client/helpers/can_use_cases.test.ts +++ b/x-pack/plugins/cases/public/client/helpers/can_use_cases.test.ts @@ -20,67 +20,67 @@ import { canUseCases } from './can_use_cases'; type CasesCapabilities = Pick< ApplicationStart['capabilities'], - 'securitySolutionCases' | 'observabilityCases' | 'generalCases' + 'securitySolutionCasesV2' | 'observabilityCasesV2' | 'generalCasesV2' >; const hasAll: CasesCapabilities = { - securitySolutionCases: allCasesCapabilities(), - observabilityCases: allCasesCapabilities(), - generalCases: allCasesCapabilities(), + securitySolutionCasesV2: allCasesCapabilities(), + observabilityCasesV2: allCasesCapabilities(), + generalCasesV2: allCasesCapabilities(), }; const hasNone: CasesCapabilities = { - securitySolutionCases: noCasesCapabilities(), - observabilityCases: noCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: noCasesCapabilities(), + observabilityCasesV2: noCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasSecurity: CasesCapabilities = { - securitySolutionCases: allCasesCapabilities(), - observabilityCases: noCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: allCasesCapabilities(), + observabilityCasesV2: noCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasObservability: CasesCapabilities = { - securitySolutionCases: noCasesCapabilities(), - observabilityCases: allCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: noCasesCapabilities(), + observabilityCasesV2: allCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasObservabilityWriteTrue: CasesCapabilities = { - securitySolutionCases: noCasesCapabilities(), - observabilityCases: writeCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: noCasesCapabilities(), + observabilityCasesV2: writeCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasSecurityWriteTrue: CasesCapabilities = { - securitySolutionCases: writeCasesCapabilities(), - observabilityCases: noCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: writeCasesCapabilities(), + observabilityCasesV2: noCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasObservabilityReadTrue: CasesCapabilities = { - securitySolutionCases: noCasesCapabilities(), - observabilityCases: readCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: noCasesCapabilities(), + observabilityCasesV2: readCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasSecurityReadTrue: CasesCapabilities = { - securitySolutionCases: readCasesCapabilities(), - observabilityCases: noCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: readCasesCapabilities(), + observabilityCasesV2: noCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasSecurityWriteAndObservabilityRead: CasesCapabilities = { - securitySolutionCases: writeCasesCapabilities(), - observabilityCases: readCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: writeCasesCapabilities(), + observabilityCasesV2: readCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const hasSecurityConnectors: CasesCapabilities = { - securitySolutionCases: readCasesCapabilities(), - observabilityCases: noCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: readCasesCapabilities(), + observabilityCasesV2: noCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; describe('canUseCases', () => { diff --git a/x-pack/plugins/cases/public/client/helpers/can_use_cases.ts b/x-pack/plugins/cases/public/client/helpers/can_use_cases.ts index 90b0d3b18908f..3e318132f8adf 100644 --- a/x-pack/plugins/cases/public/client/helpers/can_use_cases.ts +++ b/x-pack/plugins/cases/public/client/helpers/can_use_cases.ts @@ -7,7 +7,7 @@ import type { ApplicationStart } from '@kbn/core/public'; import { - FEATURE_ID, + FEATURE_ID_V2, GENERAL_CASES_OWNER, OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER, @@ -42,6 +42,8 @@ export const canUseCases = acc.push = acc.push || userCapabilitiesForOwner.push; acc.connectors = acc.connectors || userCapabilitiesForOwner.connectors; acc.settings = acc.settings || userCapabilitiesForOwner.settings; + acc.reopenCase = acc.reopenCase || userCapabilitiesForOwner.reopenCase; + acc.createComment = acc.createComment || userCapabilitiesForOwner.createComment; const allFromAcc = acc.create && @@ -50,7 +52,9 @@ export const canUseCases = acc.delete && acc.push && acc.connectors && - acc.settings; + acc.settings && + acc.reopenCase && + acc.createComment; acc.all = acc.all || userCapabilitiesForOwner.all || allFromAcc; @@ -65,6 +69,8 @@ export const canUseCases = push: false, connectors: false, settings: false, + reopenCase: false, + createComment: false, } ); @@ -75,8 +81,8 @@ export const canUseCases = const getFeatureID = (owner: CasesOwners) => { if (owner === GENERAL_CASES_OWNER) { - return FEATURE_ID; + return FEATURE_ID_V2; } - return `${owner}Cases`; + return `${owner}CasesV2`; }; diff --git a/x-pack/plugins/cases/public/client/helpers/capabilities.test.ts b/x-pack/plugins/cases/public/client/helpers/capabilities.test.ts index ce374243b10b2..ec1b90eee0eb1 100644 --- a/x-pack/plugins/cases/public/client/helpers/capabilities.test.ts +++ b/x-pack/plugins/cases/public/client/helpers/capabilities.test.ts @@ -14,9 +14,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": false, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": false, "update": false, } @@ -29,9 +31,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": false, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": false, "update": false, } @@ -44,9 +48,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": true, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": false, "update": false, } @@ -68,9 +74,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": false, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": false, "update": false, } @@ -83,9 +91,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": false, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": false, "update": false, } @@ -107,9 +117,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": true, "create": false, + "createComment": false, "delete": true, "push": true, "read": true, + "reopenCase": false, "settings": false, "update": true, } @@ -132,9 +144,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": true, + "createComment": false, "delete": true, "push": true, "read": true, + "reopenCase": false, "settings": true, "update": true, } @@ -157,9 +171,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": true, "create": true, + "createComment": false, "delete": true, "push": true, "read": true, + "reopenCase": false, "settings": false, "update": true, } @@ -172,9 +188,11 @@ describe('getUICapabilities', () => { "all": false, "connectors": false, "create": false, + "createComment": false, "delete": false, "push": false, "read": false, + "reopenCase": false, "settings": true, "update": false, } diff --git a/x-pack/plugins/cases/public/client/helpers/capabilities.ts b/x-pack/plugins/cases/public/client/helpers/capabilities.ts index 9be5b5f05f646..634cb3188602d 100644 --- a/x-pack/plugins/cases/public/client/helpers/capabilities.ts +++ b/x-pack/plugins/cases/public/client/helpers/capabilities.ts @@ -14,6 +14,8 @@ import { PUSH_CASES_CAPABILITY, READ_CASES_CAPABILITY, UPDATE_CASES_CAPABILITY, + CASES_REOPEN_CAPABILITY, + CREATE_COMMENT_CAPABILITY, } from '../../../common/constants'; export const getUICapabilities = ( @@ -26,8 +28,19 @@ export const getUICapabilities = ( const push = !!featureCapabilities?.[PUSH_CASES_CAPABILITY]; const connectors = !!featureCapabilities?.[CASES_CONNECTORS_CAPABILITY]; const settings = !!featureCapabilities?.[CASES_SETTINGS_CAPABILITY]; + const reopenCase = !!featureCapabilities?.[CASES_REOPEN_CAPABILITY]; + const createComment = !!featureCapabilities?.[CREATE_COMMENT_CAPABILITY]; - const all = create && read && update && deletePriv && push && connectors && settings; + const all = + create && + read && + update && + deletePriv && + push && + connectors && + settings && + reopenCase && + createComment; return { all, @@ -38,5 +51,7 @@ export const getUICapabilities = ( push, connectors, settings, + reopenCase, + createComment, }; }; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts index 7bf4e71e0717a..5e65dd0933e0e 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/__mocks__/index.ts @@ -48,7 +48,7 @@ export const useNavigation = jest.fn().mockReturnValue({ export const useApplicationCapabilities = jest.fn().mockReturnValue({ actions: { crud: true, read: true }, - generalCases: { crud: true, read: true }, + generalCasesV2: { crud: true, read: true }, visualize: { crud: true, read: true }, dashboard: { crud: true, read: true }, }); diff --git a/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx b/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx index 8d0beb130edc6..60b798d37822a 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.test.tsx @@ -23,7 +23,7 @@ describe('hooks', () => { expect(result.current).toEqual({ actions: { crud: true, read: true }, - generalCases: allCasesPermissions(), + generalCasesV2: allCasesPermissions(), visualize: { crud: true, read: true }, dashboard: { crud: true, read: true }, }); 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 3d72e5ca552b9..6a309111ceddb 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/cases/public/common/lib/kibana/hooks.ts @@ -15,7 +15,7 @@ import type { NavigateToAppOptions } from '@kbn/core/public'; import { getUICapabilities } from '../../../client/helpers/capabilities'; import { convertToCamelCase } from '../../../api/utils'; import { - FEATURE_ID, + FEATURE_ID_V2, DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ, } from '../../../../common/constants'; @@ -166,7 +166,7 @@ interface Capabilities { } interface UseApplicationCapabilities { actions: Capabilities; - generalCases: CasesPermissions; + generalCasesV2: CasesPermissions; visualize: Capabilities; dashboard: Capabilities; } @@ -178,13 +178,13 @@ interface UseApplicationCapabilities { export const useApplicationCapabilities = (): UseApplicationCapabilities => { const capabilities = useKibana().services?.application?.capabilities; - const casesCapabilities = capabilities[FEATURE_ID]; + const casesCapabilities = capabilities[FEATURE_ID_V2]; const permissions = getUICapabilities(casesCapabilities); return useMemo( () => ({ actions: { crud: !!capabilities.actions?.save, read: !!capabilities.actions?.show }, - generalCases: { + generalCasesV2: { all: permissions.all, create: permissions.create, read: permissions.read, @@ -193,6 +193,8 @@ export const useApplicationCapabilities = (): UseApplicationCapabilities => { push: permissions.push, connectors: permissions.connectors, settings: permissions.settings, + reopenCase: permissions.reopenCase, + createComment: permissions.createComment, }, visualize: { crud: !!capabilities.visualize?.save, read: !!capabilities.visualize?.show }, dashboard: { @@ -215,6 +217,8 @@ export const useApplicationCapabilities = (): UseApplicationCapabilities => { permissions.push, permissions.connectors, permissions.settings, + permissions.reopenCase, + permissions.createComment, ] ); }; diff --git a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx index 0223e4648ac93..48ef98c8dffa8 100644 --- a/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx +++ b/x-pack/plugins/cases/public/common/lib/kibana/kibana_react.mock.tsx @@ -83,7 +83,7 @@ export const createStartServicesMock = ({ license }: StartServiceArgs = {}): Sta services.application.capabilities = { ...services.application.capabilities, actions: { save: true, show: true }, - generalCases: { + generalCasesV2: { create_cases: true, read_cases: true, update_cases: true, @@ -91,6 +91,8 @@ export const createStartServicesMock = ({ license }: StartServiceArgs = {}): Sta push_cases: true, cases_connectors: true, cases_settings: true, + case_reopen: true, + create_comment: true, }, visualize: { save: true, show: true }, dashboard: { show: true, createNew: true }, diff --git a/x-pack/plugins/cases/public/common/mock/permissions.ts b/x-pack/plugins/cases/public/common/mock/permissions.ts index fce274cd7f338..9e08120a8c275 100644 --- a/x-pack/plugins/cases/public/common/mock/permissions.ts +++ b/x-pack/plugins/cases/public/common/mock/permissions.ts @@ -17,6 +17,8 @@ export const noCasesPermissions = () => push: false, connectors: false, settings: false, + createComment: false, + reopenCase: false, }); export const readCasesPermissions = () => @@ -28,16 +30,52 @@ export const readCasesPermissions = () => push: false, connectors: true, settings: false, + createComment: false, + reopenCase: false, }); export const noCreateCasesPermissions = () => buildCasesPermissions({ create: false }); -export const noUpdateCasesPermissions = () => buildCasesPermissions({ update: false }); +export const noCreateCommentCasesPermissions = () => + buildCasesPermissions({ createComment: false }); +export const noUpdateCasesPermissions = () => + buildCasesPermissions({ update: false, reopenCase: false }); export const noPushCasesPermissions = () => buildCasesPermissions({ push: false }); export const noDeleteCasesPermissions = () => buildCasesPermissions({ delete: false }); +export const noReopenCasesPermissions = () => buildCasesPermissions({ reopenCase: false }); export const writeCasesPermissions = () => buildCasesPermissions({ read: false }); +export const onlyCreateCommentPermissions = () => + buildCasesPermissions({ + read: false, + create: false, + update: false, + delete: true, + push: false, + createComment: true, + reopenCase: false, + }); export const onlyDeleteCasesPermission = () => - buildCasesPermissions({ read: false, create: false, update: false, delete: true, push: false }); + buildCasesPermissions({ + read: false, + create: false, + update: false, + delete: true, + push: false, + createComment: false, + reopenCase: false, + }); +// In practice, a real life user should never have this configuration, but testing for thoroughness +export const onlyReopenCasesPermission = () => + buildCasesPermissions({ + read: false, + create: false, + update: false, + delete: false, + push: false, + createComment: false, + reopenCase: true, + }); export const noConnectorsCasePermission = () => buildCasesPermissions({ connectors: false }); export const noCasesSettingsPermission = () => buildCasesPermissions({ settings: false }); +export const disabledReopenCasePermission = () => buildCasesPermissions({ reopenCase: false }); export const buildCasesPermissions = (overrides: Partial> = {}) => { const create = overrides.create ?? true; @@ -47,7 +85,18 @@ export const buildCasesPermissions = (overrides: Partial push_cases: false, cases_connectors: false, cases_settings: false, + create_comment: false, + case_reopen: false, }); export const readCasesCapabilities = () => buildCasesCapabilities({ @@ -79,6 +132,8 @@ export const readCasesCapabilities = () => delete_cases: false, push_cases: false, cases_settings: false, + create_comment: false, + case_reopen: false, }); export const writeCasesCapabilities = () => { return buildCasesCapabilities({ @@ -95,5 +150,7 @@ export const buildCasesCapabilities = (overrides?: Partial) = push_cases: overrides?.push_cases ?? true, cases_connectors: overrides?.cases_connectors ?? true, cases_settings: overrides?.cases_settings ?? true, + create_comment: overrides?.create_comment ?? true, + case_reopen: overrides?.case_reopen ?? true, }; }; diff --git a/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.test.tsx b/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.test.tsx new file mode 100644 index 0000000000000..37957c9fe1f8e --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { CaseStatuses } from '../../../../common/types/domain'; +import { useUserPermissions } from '../../user_actions/use_user_permissions'; +import { useShouldDisableStatus } from './use_should_disable_status'; + +jest.mock('../../user_actions/use_user_permissions'); +const mockUseUserPermissions = useUserPermissions as jest.Mock; + +describe('useShouldDisableStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should disable status when user has no permissions', () => { + mockUseUserPermissions.mockReturnValue({ + canUpdate: false, + canReopenCase: false, + }); + + const { result } = renderHook(() => useShouldDisableStatus()); + + const cases = [{ status: CaseStatuses.open }]; + expect(result.current(cases)).toBe(true); + }); + + it('should allow status change when user has all permissions', () => { + mockUseUserPermissions.mockReturnValue({ + canUpdate: true, + canReopenCase: true, + }); + + const { result } = renderHook(() => useShouldDisableStatus()); + + const cases = [{ status: CaseStatuses.open }]; + expect(result.current(cases)).toBe(false); + }); + + it('should only allow reopening when user can only reopen cases', () => { + mockUseUserPermissions.mockReturnValue({ + canUpdate: false, + canReopenCase: true, + }); + + const { result } = renderHook(() => useShouldDisableStatus()); + + const cases = [{ status: CaseStatuses.closed }, { status: CaseStatuses.open }]; + + expect(result.current(cases)).toBe(false); + + const closedCases = [{ status: CaseStatuses.closed }]; + expect(result.current(closedCases)).toBe(false); + }); + + it('should prevent reopening closed cases when user cannot reopen', () => { + mockUseUserPermissions.mockReturnValue({ + canUpdate: true, + canReopenCase: false, + }); + + const { result } = renderHook(() => useShouldDisableStatus()); + + const closedCases = [{ status: CaseStatuses.closed }]; + expect(result.current(closedCases)).toBe(true); + + const openCases = [{ status: CaseStatuses.open }]; + expect(result.current(openCases)).toBe(false); + }); + + it('should handle multiple selected cases correctly', () => { + mockUseUserPermissions.mockReturnValue({ + canUpdate: true, + canReopenCase: false, + }); + + const { result } = renderHook(() => useShouldDisableStatus()); + + const mixedCases = [{ status: CaseStatuses.open }, { status: CaseStatuses.closed }]; + + expect(result.current(mixedCases)).toBe(true); + }); +}); diff --git a/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.tsx b/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.tsx new file mode 100644 index 0000000000000..e329a3c8787b7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/status/use_should_disable_status.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useCallback } from 'react'; +import type { CasesUI } from '../../../../common'; +import { CaseStatuses } from '../../../../common/types/domain'; + +import { useUserPermissions } from '../../user_actions/use_user_permissions'; + +export const useShouldDisableStatus = () => { + const { canUpdate, canReopenCase } = useUserPermissions(); + + const shouldDisableStatusFn = useCallback( + (selectedCases: Array>) => { + // Read Only + Disabled => Cannot do anything + const missingAllUpdatePermissions = !canUpdate && !canReopenCase; + if (missingAllUpdatePermissions) return true; + + // All + Enabled reopen => can change status at any point in any way + if (canUpdate && canReopenCase) return false; + + const selectedCasesContainsClosed = selectedCases.some( + (theCase) => theCase.status === CaseStatuses.closed + ); + + if (selectedCasesContainsClosed) { + return !canReopenCase; + } else { + return !canUpdate; + } + }, + [canReopenCase, canUpdate] + ); + + return shouldDisableStatusFn; +}; diff --git a/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx b/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx index bb4aef3379aa3..5ad7f9803dd67 100644 --- a/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx +++ b/x-pack/plugins/cases/public/components/actions/status/use_status_action.test.tsx @@ -13,7 +13,11 @@ import { useStatusAction } from './use_status_action'; import * as api from '../../../containers/api'; import { basicCase } from '../../../containers/mock'; import { CaseStatuses } from '../../../../common/types/domain'; +import { useUserPermissions } from '../../user_actions/use_user_permissions'; +import { useShouldDisableStatus } from './use_should_disable_status'; +jest.mock('../../user_actions/use_user_permissions'); +jest.mock('./use_should_disable_status'); jest.mock('../../../containers/api'); describe('useStatusAction', () => { @@ -24,6 +28,12 @@ describe('useStatusAction', () => { beforeEach(() => { appMockRender = createAppMockRenderer(); jest.clearAllMocks(); + (useShouldDisableStatus as jest.Mock).mockReturnValue(() => false); + + (useUserPermissions as jest.Mock).mockReturnValue({ + canUpdate: true, + canReopenCase: true, + }); }); it('renders an action', async () => { @@ -43,7 +53,7 @@ describe('useStatusAction', () => { Array [ Object { "data-test-subj": "cases-bulk-action-status-open", - "disabled": true, + "disabled": false, "icon": "empty", "key": "cases-bulk-action-status-open", "name": "Open", @@ -172,6 +182,8 @@ describe('useStatusAction', () => { ]; it.each(disabledTests)('disables the status button correctly: %s', async (status, index) => { + (useShouldDisableStatus as jest.Mock).mockReturnValue(() => true); + const { result } = renderHook( () => useStatusAction({ onAction, onActionSuccess, isDisabled: false }), { @@ -197,4 +209,36 @@ describe('useStatusAction', () => { expect(actions[index].disabled).toBe(true); } ); + + it('respects user permissions when everything is false', () => { + (useUserPermissions as jest.Mock).mockReturnValue({ + canUpdate: false, + canReopenCase: false, + }); + + const { result } = renderHook( + () => useStatusAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current.canUpdateStatus).toBe(false); + }); + + it('respects user permissions when only reopen is true', () => { + (useUserPermissions as jest.Mock).mockReturnValue({ + canUpdate: false, + canReopenCase: true, + }); + + const { result } = renderHook( + () => useStatusAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current.canUpdateStatus).toBe(true); + }); }); diff --git a/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx b/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx index eb00800961085..abbc0535656d3 100644 --- a/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx +++ b/x-pack/plugins/cases/public/components/actions/status/use_status_action.tsx @@ -14,7 +14,8 @@ import { CaseStatuses } from '../../../../common/types/domain'; import * as i18n from './translations'; import type { UseActionProps } from '../types'; import { statuses } from '../../status'; -import { useCasesContext } from '../../cases_context/use_cases_context'; +import { useUserPermissions } from '../../user_actions/use_user_permissions'; +import { useShouldDisableStatus } from './use_should_disable_status'; const getStatusToasterMessage = (status: CaseStatuses, cases: CasesUI): string => { const totalCases = cases.length; @@ -35,9 +36,6 @@ interface UseStatusActionProps extends UseActionProps { selectedStatus?: CaseStatuses; } -const shouldDisableStatus = (cases: CasesUI, status: CaseStatuses) => - cases.every((theCase) => theCase.status === status); - export const useStatusAction = ({ onAction, onActionSuccess, @@ -45,10 +43,7 @@ export const useStatusAction = ({ selectedStatus, }: UseStatusActionProps) => { const { mutate: updateCases } = useUpdateCases(); - const { permissions } = useCasesContext(); - const canUpdateStatus = permissions.update; - const isActionDisabled = isDisabled || !canUpdateStatus; - + const { canUpdate, canReopenCase } = useUserPermissions(); const handleUpdateCaseStatus = useCallback( (selectedCases: CasesUI, status: CaseStatuses) => { onAction(); @@ -69,6 +64,8 @@ export const useStatusAction = ({ [onAction, updateCases, onActionSuccess] ); + const shouldDisableStatus = useShouldDisableStatus(); + const getStatusIcon = (status: CaseStatuses): string => selectedStatus && selectedStatus === status ? 'check' : 'empty'; @@ -78,7 +75,7 @@ export const useStatusAction = ({ name: statuses[CaseStatuses.open].label, icon: getStatusIcon(CaseStatuses.open), onClick: () => handleUpdateCaseStatus(selectedCases, CaseStatuses.open), - disabled: isActionDisabled || shouldDisableStatus(selectedCases, CaseStatuses.open), + disabled: isDisabled || shouldDisableStatus(selectedCases), 'data-test-subj': 'cases-bulk-action-status-open', key: 'cases-bulk-action-status-open', }, @@ -86,8 +83,7 @@ export const useStatusAction = ({ name: statuses[CaseStatuses['in-progress']].label, icon: getStatusIcon(CaseStatuses['in-progress']), onClick: () => handleUpdateCaseStatus(selectedCases, CaseStatuses['in-progress']), - disabled: - isActionDisabled || shouldDisableStatus(selectedCases, CaseStatuses['in-progress']), + disabled: isDisabled || shouldDisableStatus(selectedCases), 'data-test-subj': 'cases-bulk-action-status-in-progress', key: 'cases-bulk-action-status-in-progress', }, @@ -95,14 +91,14 @@ export const useStatusAction = ({ name: statuses[CaseStatuses.closed].label, icon: getStatusIcon(CaseStatuses.closed), onClick: () => handleUpdateCaseStatus(selectedCases, CaseStatuses.closed), - disabled: isActionDisabled || shouldDisableStatus(selectedCases, CaseStatuses.closed), + disabled: isDisabled || shouldDisableStatus(selectedCases), 'data-test-subj': 'cases-bulk-action-status-closed', key: 'cases-bulk-status-action', }, ]; }; - return { getActions, canUpdateStatus }; + return { getActions, canUpdateStatus: canUpdate || canReopenCase }; }; export type UseStatusAction = ReturnType; 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 5664151aa6df0..60fcb320ddfd0 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 @@ -10,7 +10,12 @@ import { waitFor, act, fireEvent, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { noop } from 'lodash/fp'; -import { noCreateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock'; +import { + onlyCreateCommentPermissions, + noCreateCommentCasesPermissions, + TestProviders, + createAppMockRenderer, +} from '../../common/mock'; import { AttachmentType } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER, MAX_COMMENT_LENGTH } from '../../../common/constants'; @@ -93,19 +98,36 @@ describe('AddComment ', () => { expect(screen.getByTestId('submit-comment')).toHaveAttribute('disabled'); }); - it('should hide the component when the user does not have create permissions', () => { + it('should hide the component when the user does not have createComment permissions', () => { createAttachmentsMock.mockImplementation(() => ({ ...defaultResponse, isLoading: true, })); appMockRender.render( - + ); expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('add-comment-form-wrapper')).not.toBeInTheDocument(); + }); + + it('should show the component when the user does not have create permissions, but has createComment permissions', () => { + createAttachmentsMock.mockImplementation(() => ({ + ...defaultResponse, + isLoading: true, + })); + + appMockRender.render( + + + + ); + + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + expect(screen.queryByTestId('add-comment-form-wrapper')).toBeInTheDocument(); }); it('should post comment on submit click', async () => { 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 c84f799b1c899..11d3b89eb13d2 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -191,8 +191,8 @@ export const AddComment = React.memo( size="xl" /> )} - {permissions.create && ( -
+ {permissions.createComment && ( + { expect(res.getByTestId(`case-action-popover-button-${basicCase.id}`)).toBeDisabled(); }); }); + + it('shows actions when user only has reopenCase permission and only when case is closed', async () => { + appMockRender = createAppMockRenderer({ + permissions: { + all: false, + read: true, + create: false, + update: false, + delete: false, + reopenCase: true, + push: false, + connectors: true, + settings: false, + createComment: false, + }, + }); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.actions).not.toBe(null); + const caseWithClosedStatus = { ...basicCase, status: CaseStatuses.closed }; + const comp = result.current.actions!.render(caseWithClosedStatus) as React.ReactElement; + const res = appMockRender.render(comp); + + await user.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + await waitForEuiPopoverOpen(); + + expect(res.queryByTestId(`case-action-status-panel-${basicCase.id}`)).toBeInTheDocument(); + expect(res.queryByTestId(`case-action-severity-panel-${basicCase.id}`)).toBeFalsy(); + expect(res.queryByTestId('cases-bulk-action-delete')).toBeFalsy(); + expect(res.getByTestId('cases-action-copy-id')).toBeInTheDocument(); + expect(res.queryByTestId(`actions-separator-${basicCase.id}`)).toBeFalsy(); + }); + + it('shows actions with combination of reopenCase and other permissions', async () => { + appMockRender = createAppMockRenderer({ + permissions: { + all: false, + read: true, + create: false, + update: false, + delete: true, + reopenCase: true, + push: false, + connectors: true, + settings: false, + createComment: false, + }, + }); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.actions).not.toBe(null); + const caseWithClosedStatus = { ...basicCase, status: CaseStatuses.closed }; + + const comp = result.current.actions!.render(caseWithClosedStatus) as React.ReactElement; + const res = appMockRender.render(comp); + + await user.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`)); + await waitForEuiPopoverOpen(); + + expect(res.queryByTestId(`case-action-status-panel-${basicCase.id}`)).toBeInTheDocument(); + expect(res.queryByTestId(`case-action-severity-panel-${basicCase.id}`)).toBeFalsy(); + expect(res.getByTestId('cases-bulk-action-delete')).toBeInTheDocument(); + expect(res.getByTestId('cases-action-copy-id')).toBeInTheDocument(); + }); + + it('shows no actions with everything false but read', async () => { + appMockRender = createAppMockRenderer({ + permissions: { + all: false, + read: true, + create: false, + update: false, + delete: false, + reopenCase: false, + push: false, + connectors: true, + settings: false, + createComment: false, + }, + }); + + const { result } = renderHook(() => useActions({ disableActions: false }), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.actions).toBe(null); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx b/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx index 4c43201b1eab4..e34f64a2a6283 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx @@ -28,6 +28,7 @@ import { EditTagsFlyout } from '../actions/tags/edit_tags_flyout'; import { useAssigneesAction } from '../actions/assignees/use_assignees_action'; import { EditAssigneesFlyout } from '../actions/assignees/edit_assignees_flyout'; import { useCopyIDAction } from '../actions/copy_id/use_copy_id_action'; +import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean }> = ({ theCase, @@ -38,6 +39,12 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean const closePopover = useCallback(() => setIsPopoverOpen(false), []); const refreshCases = useRefreshCases(); + const shouldDisable = useShouldDisableStatus(); + + const shouldDisableStatus = useMemo(() => { + return shouldDisable([theCase]); + }, [theCase, shouldDisable]); + const deleteAction = useDeleteAction({ isDisabled: false, onAction: closePopover, @@ -83,7 +90,7 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean { id: 0, items: mainPanelItems, title: i18n.ACTIONS }, ]; - if (canUpdate) { + if (!shouldDisableStatus) { mainPanelItems.push({ name: ( { const { permissions } = useCasesContext(); - const shouldShowActions = permissions.update || permissions.delete; + const shouldShowActions = permissions.update || permissions.delete || permissions.reopenCase; return { actions: shouldShowActions diff --git a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx index fcf3da36fba96..1838ee3b14f59 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx @@ -17,10 +17,12 @@ import { createAppMockRenderer, noDeleteCasesPermissions, onlyDeleteCasesPermission, + noReopenCasesPermissions, + onlyReopenCasesPermission, } from '../../common/mock'; import { useBulkActions } from './use_bulk_actions'; import * as api from '../../containers/api'; -import { basicCase } from '../../containers/mock'; +import { basicCase, basicCaseClosed } from '../../containers/mock'; jest.mock('../../containers/api'); jest.mock('../../containers/user_profiles/api'); @@ -117,7 +119,7 @@ describe('useBulkActions', () => { "items": Array [ Object { "data-test-subj": "cases-bulk-action-status-open", - "disabled": true, + "disabled": false, "icon": "empty", "key": "cases-bulk-action-status-open", "name": "Open", @@ -523,5 +525,72 @@ describe('useBulkActions', () => { expect(res.queryByTestId('bulk-actions-separator')).toBeFalsy(); }); }); + + it('shows the correct actions with no reopen permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: noReopenCasesPermissions() }); + const { result, waitFor: waitForHook } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCaseClosed] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const modals = result.current.modals; + const panels = result.current.panels; + + const res = appMockRender.render( + <> + + {modals} + + ); + + await waitForHook(() => { + expect(res.queryByTestId('case-bulk-action-status')).toBeInTheDocument(); + res.queryByTestId('case-bulk-action-status')?.click(); + }); + + await waitForHook(() => { + expect(res.queryByTestId('cases-bulk-action-status-open')).toBeDisabled(); + expect(res.queryByTestId('cases-bulk-action-status-in-progress')).toBeDisabled(); + expect(res.queryByTestId('cases-bulk-action-status-closed')).toBeDisabled(); + }); + }); + + it('shows the correct actions with reopen permissions', async () => { + appMockRender = createAppMockRenderer({ permissions: onlyReopenCasesPermission() }); + const { result } = renderHook( + () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCaseClosed] }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const { modals, flyouts, panels } = result.current; + const renderResult = appMockRender.render( + <> + + {modals} + {flyouts} + + ); + + await waitFor(() => { + expect(renderResult.queryByTestId('case-bulk-action-status')).toBeInTheDocument(); + expect(renderResult.queryByTestId('case-bulk-action-severity')).toBeInTheDocument(); + expect(renderResult.queryByTestId('bulk-actions-separator')).not.toBeInTheDocument(); + expect(renderResult.queryByTestId('case-bulk-action-delete')).not.toBeInTheDocument(); + }); + + userEvent.click(renderResult.getByTestId('case-bulk-action-status')); + + await waitFor(() => { + expect(renderResult.queryByTestId('cases-bulk-action-status-open')).not.toBeDisabled(); + expect( + renderResult.queryByTestId('cases-bulk-action-status-in-progress') + ).not.toBeDisabled(); + expect(renderResult.queryByTestId('cases-bulk-action-status-closed')).not.toBeDisabled(); + }); + }); }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx index 009dfbf99f262..98828b00369f5 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx @@ -76,9 +76,6 @@ export const useBulkActions = ({ const panels = useMemo((): EuiContextMenuPanelDescriptor[] => { const mainPanelItems: EuiContextMenuPanelItemDescriptor[] = []; - const panelsToBuild: EuiContextMenuPanelDescriptor[] = [ - { id: 0, items: mainPanelItems, title: i18n.ACTIONS }, - ]; if (canUpdate) { mainPanelItems.push({ @@ -119,7 +116,13 @@ export const useBulkActions = ({ if (canDelete) { mainPanelItems.push(deleteAction.getAction(selectedCases)); } - + const panelsToBuild: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + items: [...mainPanelItems], // Create a new array instead of using reference + title: i18n.ACTIONS, + }, + ]; if (canUpdate) { panelsToBuild.push({ id: 1, diff --git a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx index 6808735a41184..389de5068ed51 100644 --- a/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/utility_bar.tsx @@ -94,7 +94,9 @@ export const CasesTableUtilityBar: FunctionComponent = React.memo( * Granular permission check for each action is performed * in the useBulkActions hook. */ - const showBulkActions = (permissions.update || permissions.delete) && selectedCases.length > 0; + const showBulkActions = + (permissions.update || permissions.delete || permissions.reopenCase) && + selectedCases.length > 0; const visibleCases = pagination?.pageSize && totalCases > pagination.pageSize ? pagination.pageSize : totalCases; diff --git a/x-pack/plugins/cases/public/components/app/index.tsx b/x-pack/plugins/cases/public/components/app/index.tsx index cc6c572275721..eaa334470ab0f 100644 --- a/x-pack/plugins/cases/public/components/app/index.tsx +++ b/x-pack/plugins/cases/public/components/app/index.tsx @@ -39,7 +39,7 @@ const CasesAppComponent: React.FC = ({ getFilesClient, owner: [APP_OWNER], useFetchAlertData: () => [false, {}], - permissions: userCapabilities.generalCases, + permissions: userCapabilities.generalCasesV2, basePath: '/', features: { alerts: { enabled: true, sync: false } }, })} diff --git a/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts b/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts index a26647704785f..4cd015de0c92e 100644 --- a/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts +++ b/x-pack/plugins/cases/public/components/app/use_available_owners.test.ts @@ -21,15 +21,15 @@ jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.MockedFunction; const hasAll = { - securitySolutionCases: allCasesCapabilities(), - observabilityCases: allCasesCapabilities(), - generalCases: allCasesCapabilities(), + securitySolutionCasesV2: allCasesCapabilities(), + observabilityCasesV2: allCasesCapabilities(), + generalCasesV2: allCasesCapabilities(), }; const secAllObsReadGenNone = { - securitySolutionCases: allCasesCapabilities(), - observabilityCases: readCasesCapabilities(), - generalCases: noCasesCapabilities(), + securitySolutionCasesV2: allCasesCapabilities(), + observabilityCasesV2: readCasesCapabilities(), + generalCasesV2: noCasesCapabilities(), }; const unrelatedFeatures = { diff --git a/x-pack/plugins/cases/public/components/app/use_available_owners.ts b/x-pack/plugins/cases/public/components/app/use_available_owners.ts index c829b9c590d01..4220ff8cdecd4 100644 --- a/x-pack/plugins/cases/public/components/app/use_available_owners.ts +++ b/x-pack/plugins/cases/public/components/app/use_available_owners.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { APP_ID, FEATURE_ID } from '../../../common/constants'; +import { APP_ID, FEATURE_ID_V2 } from '../../../common/constants'; import { useKibana } from '../../common/lib/kibana'; import type { CasesPermissions } from '../../containers/types'; import { allCasePermissions } from '../../utils/permissions'; @@ -25,7 +25,7 @@ export const useAvailableCasesOwners = ( return Object.entries(kibanaCapabilities).reduce( (availableOwners: string[], [featureId, kibanaCapability]) => { - if (!featureId.endsWith('Cases')) { + if (!featureId.endsWith('CasesV2')) { return availableOwners; } for (const cap of capabilities) { @@ -42,9 +42,9 @@ export const useAvailableCasesOwners = ( }; const getOwnerFromFeatureID = (featureID: string) => { - if (featureID === FEATURE_ID) { + if (featureID === FEATURE_ID_V2) { return APP_ID; } - return featureID.replace('Cases', ''); + return featureID.replace('CasesV2', ''); }; 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 d6c17febb6348..7fd13396086c7 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 @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { css } from '@emotion/react'; import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiButtonEmpty, useEuiTheme } from '@elastic/eui'; import type { CaseStatuses } from '../../../common/types/domain'; @@ -23,6 +23,7 @@ import { useRefreshCaseViewPage } from '../case_view/use_on_refresh_case_view_pa import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesFeatures } from '../../common/use_cases_features'; import { useGetCaseConnectors } from '../../containers/use_get_case_connectors'; +import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; export interface CaseActionBarProps { caseData: CaseUI; @@ -67,6 +68,11 @@ const CaseActionBarComponent: React.FC = ({ [caseData.settings, onUpdateField] ); + const shouldDisableStatusFn = useShouldDisableStatus(); + const isStatusMenuDisabled = useMemo(() => { + return shouldDisableStatusFn([caseData]); + }, [caseData, shouldDisableStatusFn]); + return ( = ({ 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 95d36bb058d79..e4497b14ff75e 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 @@ -10,17 +10,24 @@ import { mount } from 'enzyme'; import { CaseStatuses } from '../../../common/types/domain'; import { StatusContextMenu } from './status_context_menu'; +import { TestProviders } from '../../common/mock'; +import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; -describe('SyncAlertsSwitch', () => { +jest.mock('../actions/status/use_should_disable_status'); + +describe('StatusContextMenu', () => { const onStatusChanged = jest.fn(); beforeEach(() => { jest.clearAllMocks(); + (useShouldDisableStatus as jest.Mock).mockReturnValue(() => false); }); it('renders', async () => { const wrapper = mount( - + + + ); expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).exists()).toBeTruthy(); @@ -28,11 +35,13 @@ describe('SyncAlertsSwitch', () => { it('renders a simple status badge when disabled', async () => { const wrapper = mount( - + + + ); expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).exists()).toBeFalsy(); @@ -41,7 +50,9 @@ describe('SyncAlertsSwitch', () => { it('renders the current status correctly', async () => { const wrapper = mount( - + + + ); expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toBe( @@ -51,7 +62,9 @@ describe('SyncAlertsSwitch', () => { it('changes the status', async () => { const wrapper = mount( - + + + ); wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).simulate('click'); @@ -62,14 +75,61 @@ describe('SyncAlertsSwitch', () => { expect(onStatusChanged).toHaveBeenCalledWith('in-progress'); }); - it('does not call onStatusChanged if selection is same as current status', async () => { + it('does not render the button at all if the status cannot change', async () => { + (useShouldDisableStatus as jest.Mock).mockReturnValue(() => true); const wrapper = mount( - + + + ); wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).simulate('click'); - wrapper.find(`[data-test-subj="case-view-status-dropdown-open"] button`).simulate('click'); + expect(wrapper.find(`[data-test-subj="case-view-status-dropdown-open"] button`)).toHaveLength( + 0 + ); expect(onStatusChanged).not.toHaveBeenCalled(); }); + + it('updates menu items when shouldDisableStatus changes', async () => { + const mockShouldDisableStatus = jest.fn().mockReturnValue(false); + (useShouldDisableStatus as jest.Mock).mockReturnValue(mockShouldDisableStatus); + + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).simulate('click'); + + expect(mockShouldDisableStatus).toHaveBeenCalledWith([{ status: CaseStatuses.open }]); + }); + + it('handles all statuses being disabled', async () => { + (useShouldDisableStatus as jest.Mock).mockReturnValue(() => true); + + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).simulate('click'); + expect(wrapper.find('EuiContextMenuItem').prop('onClick')).toBeUndefined(); + }); + + it('correctly evaluates each status option', async () => { + (useShouldDisableStatus as jest.Mock).mockReturnValue(false); + + const wrapper = mount( + + + + ); + + expect( + wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).exists() + ).toBeFalsy(); + }); }); 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 422cf1aa44b80..b1c65fc796b46 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 @@ -12,6 +12,7 @@ import type { CaseStatuses } from '../../../common/types/domain'; import { caseStatuses } from '../../../common/types/domain'; import { StatusPopoverButton } from '../status'; import { CHANGE_STATUS } from '../all_cases/translations'; +import { useShouldDisableStatus } from '../actions/status/use_should_disable_status'; interface Props { currentStatus: CaseStatuses; @@ -27,6 +28,7 @@ const StatusContextMenuComponent: React.FC = ({ onStatusChanged, }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const shouldDisableStatus = useShouldDisableStatus(); const togglePopover = useCallback( () => setIsPopoverOpen((prevPopoverStatus) => !prevPopoverStatus), [] @@ -57,17 +59,19 @@ const StatusContextMenuComponent: React.FC = ({ const panelItems = useMemo( () => - caseStatuses.map((status: CaseStatuses) => ( - onContextMenuItemClick(status)} - > - - - )), - [currentStatus, onContextMenuItemClick] + caseStatuses + .filter((_: CaseStatuses) => !shouldDisableStatus([{ status: currentStatus }])) + .map((status: CaseStatuses) => ( + onContextMenuItemClick(status)} + > + + + )), + [currentStatus, onContextMenuItemClick, shouldDisableStatus] ); if (disabled) { diff --git a/x-pack/plugins/cases/public/components/cases_context/index.tsx b/x-pack/plugins/cases/public/components/cases_context/index.tsx index 85c267f5d05d7..77aee6551ac03 100644 --- a/x-pack/plugins/cases/public/components/cases_context/index.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/index.tsx @@ -98,6 +98,8 @@ export const CasesProvider: FC< read: permissions.read, settings: permissions.settings, update: permissions.update, + reopenCase: permissions.reopenCase, + createComment: permissions.createComment, }, basePath, /** @@ -127,6 +129,8 @@ export const CasesProvider: FC< permissions.read, permissions.settings, permissions.update, + permissions.reopenCase, + permissions.createComment, ] ); diff --git a/x-pack/plugins/cases/public/components/files/add_file.test.tsx b/x-pack/plugins/cases/public/components/files/add_file.test.tsx index 69aa9e87a34e7..9a27b8780db2d 100644 --- a/x-pack/plugins/cases/public/components/files/add_file.test.tsx +++ b/x-pack/plugins/cases/public/components/files/add_file.test.tsx @@ -107,19 +107,9 @@ describe('AddFile', () => { expect(await screen.findByTestId('cases-files-add')).toBeInTheDocument(); }); - it('AddFile is not rendered if user has no create permission', async () => { + it('AddFile is not rendered if user has no createComment permission', async () => { appMockRender = createAppMockRenderer({ - permissions: buildCasesPermissions({ create: false }), - }); - - appMockRender.render(); - - expect(screen.queryByTestId('cases-files-add')).not.toBeInTheDocument(); - }); - - it('AddFile is not rendered if user has no update permission', async () => { - appMockRender = createAppMockRenderer({ - permissions: buildCasesPermissions({ update: false }), + permissions: buildCasesPermissions({ createComment: false }), }); appMockRender.render(); diff --git a/x-pack/plugins/cases/public/components/files/add_file.tsx b/x-pack/plugins/cases/public/components/files/add_file.tsx index 7b91879834a78..ab83b75920d59 100644 --- a/x-pack/plugins/cases/public/components/files/add_file.tsx +++ b/x-pack/plugins/cases/public/components/files/add_file.tsx @@ -107,7 +107,7 @@ const AddFileComponent: React.FC = ({ caseId }) => { [caseId, createAttachments, owner, refreshAttachmentsTable, showDangerToast, showSuccessToast] ); - return permissions.create && permissions.update ? ( + return permissions.createComment ? ( { it('sets all available solutions correctly', () => { appMockRender = createAppMockRenderer({ owner: [] }); /** - * We set securitySolutionCases capability to not have + * We set securitySolutionCasesV2 capability to not have * any access to cases. This tests that we get the owners * that have at least read access. */ appMockRender.coreStart.application.capabilities = { ...appMockRender.coreStart.application.capabilities, - securitySolutionCases: noCasesCapabilities(), + securitySolutionCasesV2: noCasesCapabilities(), }; appMockRender.render(); diff --git a/x-pack/plugins/cases/public/components/user_actions/index.tsx b/x-pack/plugins/cases/public/components/user_actions/index.tsx index a17dee7423fe3..793405276cdb4 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.tsx @@ -16,7 +16,6 @@ import { getManualAlertIdsWithNoRuleId } from './helpers'; import type { UserActionTreeProps } from './types'; import { useUserActionsHandler } from './use_user_actions_handler'; import { NEW_COMMENT_ID } from './constants'; -import { useCasesContext } from '../cases_context/use_cases_context'; import { UserToolTip } from '../user_profiles/user_tooltip'; import { Username } from '../user_profiles/username'; import { HoverableAvatar } from '../user_profiles/hoverable_avatar'; @@ -25,6 +24,7 @@ import { useUserActionsPagination } from './use_user_actions_pagination'; import { useLastPageUserActions } from './use_user_actions_last_page'; import { ShowMoreButton } from './show_more_button'; import { useLastPage } from './use_last_page'; +import { useUserPermissions } from './use_user_permissions'; const getIconsCss = (hasNextPage: boolean | undefined, euiTheme: EuiThemeComputed<{}>): string => { const customSize = hasNextPage @@ -108,10 +108,10 @@ export const UserActions = React.memo((props: UserActionTreeProps) => { const [loadingAlertData, manualAlertsData] = useFetchAlertData(alertIdsWithoutRuleInfo); - const { permissions } = useCasesContext(); + const { getCanAddUserComments } = useUserPermissions(); // add-comment markdown is not visible in History filter - const showCommentEditor = permissions.create && userActivityQueryParams.type !== 'action'; + const shouldShowCommentEditor = getCanAddUserComments(userActivityQueryParams); const { commentRefs, @@ -136,7 +136,7 @@ export const UserActions = React.memo((props: UserActionTreeProps) => { [caseId, handleUpdate, handleManageMarkdownEditId, statusActionButton, commentRefs] ); - const bottomActions = showCommentEditor + const bottomActions = shouldShowCommentEditor ? [ { username: ( diff --git a/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.test.tsx b/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.test.tsx new file mode 100644 index 0000000000000..e7c712b0df590 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.test.tsx @@ -0,0 +1,259 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { useUserPermissions } from './use_user_permissions'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; + +jest.mock('../cases_context/use_cases_context'); +const mockUseCasesContext = useCasesContext as jest.Mock; + +describe('useUserPermissions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('canUpdate permission', () => { + it('should return true when user has update permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: true, + reopenCase: false, + createComment: false, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + expect(result.current.canUpdate).toBe(true); + }); + + it('should return false when user lacks update permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: false, + reopenCase: true, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + expect(result.current.canUpdate).toBe(false); + }); + }); + + describe('canReopenCase permission', () => { + it('should return true when user has reopenCase permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: false, + reopenCase: true, + createComment: false, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + expect(result.current.canReopenCase).toBe(true); + }); + + it('should return false when user lacks reopenCase permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: true, + reopenCase: false, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + expect(result.current.canReopenCase).toBe(false); + }); + }); + + describe('getCanAddUserComments permission', () => { + it('should return false when activity type is "action" regardless of createComment permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: false, + reopenCase: false, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + const userActivityParams: UserActivityParams = { + page: 1, + perPage: 10, + sortOrder: 'asc', + type: 'action', + }; + + expect(result.current.getCanAddUserComments(userActivityParams)).toBe(false); + }); + + it('should return true when type is not "action" and user has createComment permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: false, + reopenCase: false, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + const userActivityParams: UserActivityParams = { + page: 1, + perPage: 10, + sortOrder: 'asc', + type: 'user', + }; + + expect(result.current.getCanAddUserComments(userActivityParams)).toBe(true); + }); + + it('should return false when type is not "action" but user lacks createComment permission', () => { + mockUseCasesContext.mockReturnValue({ + permissions: { + update: true, + reopenCase: true, + createComment: false, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }, + }); + + const { result } = renderHook(() => useUserPermissions()); + const userActivityParams: UserActivityParams = { + page: 1, + perPage: 10, + sortOrder: 'asc', + type: 'user', + }; + + expect(result.current.getCanAddUserComments(userActivityParams)).toBe(false); + }); + }); + + it('should maintain stable references to memoized values when permissions do not change', () => { + const permissions = { + update: true, + reopenCase: true, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }; + + mockUseCasesContext.mockReturnValue({ permissions }); + + const { result, rerender } = renderHook(() => useUserPermissions()); + + const initialCanUpdate = result.current.canUpdate; + const initialCanReopenCase = result.current.canReopenCase; + const initialGetCanAddUserComments = result.current.getCanAddUserComments; + + rerender(); + + expect(result.current.canUpdate).toBe(initialCanUpdate); + expect(result.current.canReopenCase).toBe(initialCanReopenCase); + expect(result.current.getCanAddUserComments).toBe(initialGetCanAddUserComments); + }); + + it('should update memoized values when permissions change', () => { + const initialPermissions = { + update: true, + reopenCase: true, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }; + + mockUseCasesContext.mockReturnValue({ permissions: initialPermissions }); + + const { result, rerender } = renderHook(() => useUserPermissions()); + + const initialCanUpdate = result.current.canUpdate; + const initialCanReopenCase = result.current.canReopenCase; + const initialGetCanAddUserComments = result.current.getCanAddUserComments; + + const newPermissions = { + update: false, + reopenCase: false, + createComment: true, + all: false, + read: true, + create: false, + delete: false, + push: false, + connectors: true, + settings: false, + }; + + mockUseCasesContext.mockReturnValue({ permissions: newPermissions }); + rerender(); + + expect(result.current.canUpdate).not.toBe(initialCanUpdate); + expect(result.current.canReopenCase).not.toBe(initialCanReopenCase); + expect(result.current.getCanAddUserComments).toBe(initialGetCanAddUserComments); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.tsx b/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.tsx new file mode 100644 index 0000000000000..f0a79a6e285a5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/use_user_permissions.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useCallback } from 'react'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import type { UserActivityParams } from '../user_actions_activity_bar/types'; + +export const useUserPermissions = () => { + const { permissions } = useCasesContext(); + + /** + * Determines if a user has the capability to update the case. Reopening a case is not part of this capability. + */ + + const canUpdate = permissions.update; + + /** + * Determines if a user has the capability to change the case from closed => open or closed => in progress + */ + + const canReopenCase = permissions.reopenCase; + + /** + * Determines if a user has the capability to add comments and attachments + */ + const getCanAddUserComments = useCallback( + (userActivityQueryParams: UserActivityParams) => { + if (userActivityQueryParams.type === 'action') return false; + return permissions.createComment; + }, + [permissions.createComment] + ); + + return { getCanAddUserComments, canReopenCase, canUpdate }; +}; 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 53900a6920f20..92d7abde2f9d2 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 @@ -69,7 +69,7 @@ describe('useGetCases', () => { appMockRender.coreStart.application.capabilities = { ...appMockRender.coreStart.application.capabilities, - observabilityCases: { + observabilityCasesV2: { create_cases: true, read_cases: true, update_cases: true, @@ -78,7 +78,7 @@ describe('useGetCases', () => { delete_cases: true, cases_settings: true, }, - securitySolutionCases: { + securitySolutionCasesV2: { create_cases: true, read_cases: true, update_cases: true, diff --git a/x-pack/plugins/cases/public/mocks.ts b/x-pack/plugins/cases/public/mocks.ts index e267c108a9b39..3de6a96979065 100644 --- a/x-pack/plugins/cases/public/mocks.ts +++ b/x-pack/plugins/cases/public/mocks.ts @@ -50,6 +50,8 @@ const helpersMock: jest.Mocked = { push: false, connectors: false, settings: false, + createComment: false, + reopenCase: false, }), getRuleIdFromEvent: jest.fn(), groupAlertsByRule: jest.fn(), 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 index ebb9501ff8960..b8129f9111b9c 100644 --- 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 @@ -2520,6 +2520,90 @@ Object { } `; +exports[`audit_logger log function event structure creates the correct audit event for operation: "reopenCase" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_reopen", + "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: "reopenCase" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "case_reopen", + "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: "reopenCase" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "case_reopen", + "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: "reopenCase" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "case_reopen", + "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: "resolveCase" with an error and entity 1`] = ` Object { "error": Object { diff --git a/x-pack/plugins/cases/server/authorization/__snapshots__/authorization.test.ts.snap b/x-pack/plugins/cases/server/authorization/__snapshots__/authorization.test.ts.snap new file mode 100644 index 0000000000000..23575aaad0ddd --- /dev/null +++ b/x-pack/plugins/cases/server/authorization/__snapshots__/authorization.test.ts.snap @@ -0,0 +1,150 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`authorization ensureAuthorized with operation arrays handles multiple operations successfully when authorized 1`] = ` +Array [ + Array [ + Object { + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "User is creating cases [id=1] as owner \\"a\\"", + }, + ], + Array [ + Object { + "event": Object { + "action": "case_get", + "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\\"", + }, + ], +] +`; + +exports[`authorization ensureAuthorized with operation arrays logs each operation separately 1`] = ` +Array [ + Array [ + Object { + "event": Object { + "action": "case_create", + "category": Array [ + "database", + ], + "outcome": "unknown", + "type": Array [ + "creation", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "User is creating cases [id=1] as owner \\"a\\"", + }, + ], + Array [ + Object { + "event": Object { + "action": "case_get", + "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\\"", + }, + ], +] +`; + +exports[`authorization ensureAuthorized with operation arrays throws on first unauthorized operation in array 1`] = ` +Array [ + Array [ + Object { + "error": Object { + "code": "Error", + "message": "Unauthorized to create, access case with owners: \\"a\\"", + }, + "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 \\"a\\"", + }, + ], + Array [ + Object { + "error": Object { + "code": "Error", + "message": "Unauthorized to create, access case with owners: \\"a\\"", + }, + "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 \\"a\\"", + }, + ], +] +`; diff --git a/x-pack/plugins/cases/server/authorization/audit_logger.ts b/x-pack/plugins/cases/server/authorization/audit_logger.ts index 338af379bbcc7..2de847586228a 100644 --- a/x-pack/plugins/cases/server/authorization/audit_logger.ts +++ b/x-pack/plugins/cases/server/authorization/audit_logger.ts @@ -82,15 +82,18 @@ export class AuthorizationAuditLogger { operation, }: { owners: string[]; - operation: OperationDetails; + operation: OperationDetails | OperationDetails[]; }) { const ownerMsg = owners.length <= 0 ? 'of any owner' : `with owners: "${owners.join(', ')}"`; + const operations = Array.isArray(operation) ? operation : [operation]; + const operationVerbs = [...new Set(operations.map((op) => op.verbs.present))].join(', '); + const operationDocTypes = [...new Set(operations.map((op) => op.docType))].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}`; + return `Unauthorized to ${operationVerbs} ${operationDocTypes} ${ownerMsg}`; } /** diff --git a/x-pack/plugins/cases/server/authorization/authorization.test.ts b/x-pack/plugins/cases/server/authorization/authorization.test.ts index 6385bc03813a0..9ba13ed51dcb3 100644 --- a/x-pack/plugins/cases/server/authorization/authorization.test.ts +++ b/x-pack/plugins/cases/server/authorization/authorization.test.ts @@ -1459,4 +1459,80 @@ describe('authorization', () => { }); }); }); + + describe('ensureAuthorized with operation arrays', () => { + let auth: Authorization; + let securityStart: ReturnType; + let featuresStart: jest.Mocked; + let spacesStart: jest.Mocked; + + 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([ + { id: '1', cases: ['a'] }, + ] as unknown as KibanaFeature[]); + + spacesStart = createSpacesDisabledFeaturesMock(); + + auth = await Authorization.create({ + request, + securityAuth: securityStart.authz, + spaces: spacesStart, + features: featuresStart, + auditLogger: new AuthorizationAuditLogger(mockLogger), + logger: loggingSystemMock.createLogger(), + }); + }); + + it('handles multiple operations successfully when authorized', async () => { + await expect( + auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'a' }], + operation: [Operations.createCase, Operations.getCase], + }) + ).resolves.not.toThrow(); + + expect(mockLogger.log.mock.calls).toMatchSnapshot(); + }); + + it('throws on first unauthorized operation in array', async () => { + securityStart.authz.checkPrivilegesDynamicallyWithRequest.mockReturnValue( + jest.fn(async () => ({ hasAllRequested: false })) + ); + + await expect( + auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'a' }], + operation: [Operations.createCase, Operations.getCase], + }) + ).rejects.toThrow('Unauthorized to create, access case with owners: "a"'); + + expect(mockLogger.log.mock.calls).toMatchSnapshot(); + }); + + it('logs each operation separately', async () => { + await auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'a' }], + operation: [Operations.createCase, Operations.getCase], + }); + + expect(mockLogger.log).toHaveBeenCalledTimes(2); + expect(mockLogger.log.mock.calls).toMatchSnapshot(); + }); + + it('handles empty operation array', async () => { + await expect( + auth.ensureAuthorized({ + entities: [{ id: '1', owner: 'a' }], + operation: [], + }) + ).resolves.not.toThrow(); + }); + }); }); diff --git a/x-pack/plugins/cases/server/authorization/authorization.ts b/x-pack/plugins/cases/server/authorization/authorization.ts index ed255a5df18aa..f760e4498d06e 100644 --- a/x-pack/plugins/cases/server/authorization/authorization.ts +++ b/x-pack/plugins/cases/server/authorization/authorization.ts @@ -108,18 +108,17 @@ export class Authorization { operation, }: { entities: OwnerEntity[]; - operation: OperationDetails; + operation: OperationDetails | OperationDetails[]; }) { + const uniqueOwners = Array.from(new Set(entities.map((entity) => entity.owner))); + const operations = Array.isArray(operation) ? operation : [operation]; try { - const uniqueOwners = Array.from(new Set(entities.map((entity) => entity.owner))); - - await this._ensureAuthorized(uniqueOwners, operation); + await this._ensureAuthorized(uniqueOwners, operations); } catch (error) { - this.logSavedObjects({ entities, operation, error }); + this.logSavedObjects({ entities, operation: operations, error }); throw error; } - - this.logSavedObjects({ entities, operation }); + this.logSavedObjects({ entities, operation: operations }); } /** @@ -177,11 +176,15 @@ export class Authorization { error, }: { entities: OwnerEntity[]; - operation: OperationDetails; + operation: OperationDetails | OperationDetails[]; error?: Error; }) { + const operations = Array.isArray(operation) ? operation : [operation]; + for (const entity of entities) { - this.auditLogger.log({ operation, error, entity }); + for (const op of operations) { + this.auditLogger.log({ operation: op, error, entity }); + } } } @@ -197,15 +200,13 @@ export class Authorization { } } - private async _ensureAuthorized(owners: string[], operation: OperationDetails) { + private async _ensureAuthorized(owners: string[], operations: 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 requiredPrivileges: string[] = operations.flatMap((operation) => + owners.map((owner) => securityAuth.actions.cases.get(owner, operation.name)) ); - const checkPrivileges = securityAuth.checkPrivilegesDynamicallyWithRequest(this.request); const { hasAllRequested } = await checkPrivileges({ kibana: requiredPrivileges, @@ -219,14 +220,20 @@ export class Authorization { * as Privileged. * This check will ensure we don't accidentally let these through */ - throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation })); + throw Boom.forbidden( + AuthorizationAuditLogger.createFailureMessage({ owners, operation: operations }) + ); } if (!hasAllRequested) { - throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation })); + throw Boom.forbidden( + AuthorizationAuditLogger.createFailureMessage({ owners, operation: operations }) + ); } } else if (!areAllOwnersAvailable) { - throw Boom.forbidden(AuthorizationAuditLogger.createFailureMessage({ owners, operation })); + throw Boom.forbidden( + AuthorizationAuditLogger.createFailureMessage({ owners, operation: operations }) + ); } // else security is disabled so let the operation proceed @@ -288,7 +295,6 @@ export class Authorization { const { hasAllRequested, username, privileges } = await checkPrivileges({ kibana: [...requiredPrivileges.keys()], }); - return { hasAllRequested, username, diff --git a/x-pack/plugins/cases/server/authorization/index.ts b/x-pack/plugins/cases/server/authorization/index.ts index 12653aa6079e6..40b6c5d7101c5 100644 --- a/x-pack/plugins/cases/server/authorization/index.ts +++ b/x-pack/plugins/cases/server/authorization/index.ts @@ -59,7 +59,7 @@ const EVENT_TYPES: Record> = { }; /** - * These values need to match the respective values in this file: x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts + * These values need to match the respective values in this file: x-pack/packages/security/authorization_core/src/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. @@ -182,6 +182,14 @@ const CaseOperations = { docType: 'cases', savedObjectType: CASE_SAVED_OBJECT, }, + [WriteOperations.ReopenCase]: { + ecsType: EVENT_TYPES.change, + name: WriteOperations.ReopenCase as const, + action: 'case_reopen', + verbs: updateVerbs, + docType: 'case', + savedObjectType: CASE_SAVED_OBJECT, + }, }; const ConfigurationOperations = { diff --git a/x-pack/plugins/cases/server/authorization/types.ts b/x-pack/plugins/cases/server/authorization/types.ts index f97c6fc597457..1031e2db0ec77 100644 --- a/x-pack/plugins/cases/server/authorization/types.ts +++ b/x-pack/plugins/cases/server/authorization/types.ts @@ -63,6 +63,7 @@ export enum WriteOperations { UpdateComment = 'updateComment', CreateConfiguration = 'createConfiguration', UpdateConfiguration = 'updateConfiguration', + ReopenCase = 'reopenCase', } /** @@ -75,7 +76,7 @@ export interface OperationDetails { ecsType: ArrayElement; /** * 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 + * These values need to match one of the operation strings defined here: x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts * * To avoid the authorization strings getting too large, new operations should generally fit within one of the * CasesSupportedOperations. In the situation where a new one is needed we'll have to add it to the security plugin. diff --git a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts index 0109e6eda8808..755084d624b9f 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CustomFieldTypes } from '../../../common/types/domain'; +import { CustomFieldTypes, CaseStatuses } from '../../../common/types/domain'; import { MAX_CATEGORY_LENGTH, MAX_DESCRIPTION_LENGTH, @@ -19,6 +19,7 @@ import { } from '../../../common/constants'; import { mockCases } from '../../mocks'; import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; +import { Operations } from '../../authorization'; import { bulkUpdate } from './bulk_update'; describe('update', () => { @@ -1628,5 +1629,135 @@ describe('update', () => { ); }); }); + + describe('Authorization', () => { + const clientArgs = createCasesClientMockArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: mockCases }); + clientArgs.services.caseService.getAllCaseComments.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 10, + page: 1, + }); + clientArgs.services.attachmentService.getter.getCaseCommentStats.mockResolvedValue( + new Map() + ); + }); + + it('checks authorization for updateCase operation', async () => { + clientArgs.services.caseService.patchCases.mockResolvedValue({ + saved_objects: [{ ...mockCases[0] }], + }); + + await bulkUpdate( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + title: 'Updated title', + }, + ], + }, + clientArgs, + casesClientMock + ); + + expect(clientArgs.authorization.ensureAuthorized).toHaveBeenCalledWith({ + entities: [{ id: mockCases[0].id, owner: mockCases[0].attributes.owner }], + operation: [Operations.updateCase], + }); + }); + + it('checks authorization for both reopenCase and updateCase operations when reopening a case', async () => { + // Mock a closed case + const closedCase = { + ...mockCases[0], + attributes: { + ...mockCases[0].attributes, + status: CaseStatuses.closed, + }, + }; + clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: [closedCase] }); + + clientArgs.services.caseService.patchCases.mockResolvedValue({ + saved_objects: [{ ...closedCase }], + }); + + await bulkUpdate( + { + cases: [ + { + id: closedCase.id, + version: closedCase.version ?? '', + status: CaseStatuses.open, + }, + ], + }, + clientArgs, + casesClientMock + ); + + expect(clientArgs.authorization.ensureAuthorized).not.toThrow(); + }); + + it('throws when user is not authorized to update case', async () => { + const error = new Error('Unauthorized'); + clientArgs.authorization.ensureAuthorized.mockRejectedValue(error); + + await expect( + bulkUpdate( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + title: 'Updated title', + }, + ], + }, + clientArgs, + casesClientMock + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Unauthorized"` + ); + }); + + it('throws when user is not authorized to reopen case', async () => { + const closedCase = { + ...mockCases[0], + attributes: { + ...mockCases[0].attributes, + status: CaseStatuses.closed, + }, + }; + clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: [closedCase] }); + + const error = new Error('Unauthorized to reopen case'); + clientArgs.authorization.ensureAuthorized.mockRejectedValueOnce(error); // Reject reopenCase + + await expect( + bulkUpdate( + { + cases: [ + { + id: closedCase.id, + version: closedCase.version ?? '', + status: CaseStatuses.open, + }, + ], + }, + clientArgs, + casesClientMock + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Unauthorized to reopen case"` + ); + }); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/cases/bulk_update.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.ts index b9984ac53b05e..9a90168b858de 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_update.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.ts @@ -272,9 +272,11 @@ function partitionPatchRequest( conflictedCases: CasePatchRequest[]; // This will be a deduped array of case IDs with their corresponding owner casesToAuthorize: OwnerEntity[]; + reopenedCases: CasePatchRequest[]; } { const nonExistingCases: CasePatchRequest[] = []; const conflictedCases: CasePatchRequest[] = []; + const reopenedCases: CasePatchRequest[] = []; const casesToAuthorize: Map = new Map(); for (const reqCase of patchReqCases) { @@ -286,6 +288,13 @@ function partitionPatchRequest( 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 if ( + reqCase.status != null && + foundCase.attributes.status !== reqCase.status && + foundCase.attributes.status === CaseStatuses.closed + ) { + // Track cases that are closed and a user is attempting to reopen + reopenedCases.push(reqCase); } else { casesToAuthorize.set(foundCase.id, { id: foundCase.id, owner: foundCase.attributes.owner }); } @@ -294,6 +303,7 @@ function partitionPatchRequest( return { nonExistingCases, conflictedCases, + reopenedCases, casesToAuthorize: Array.from(casesToAuthorize.values()), }; } @@ -344,14 +354,17 @@ export const bulkUpdate = async ( return acc; }, new Map()); - const { nonExistingCases, conflictedCases, casesToAuthorize } = partitionPatchRequest( - casesMap, - query.cases - ); + const { nonExistingCases, conflictedCases, casesToAuthorize, reopenedCases } = + partitionPatchRequest(casesMap, query.cases); + + const operationsToAuthorize = + reopenedCases.length > 0 + ? [Operations.reopenCase, Operations.updateCase] + : [Operations.updateCase]; await authorization.ensureAuthorized({ entities: casesToAuthorize, - operation: Operations.updateCase, + operation: operationsToAuthorize, }); if (nonExistingCases.length > 0) { diff --git a/x-pack/plugins/cases/server/connectors/cases/index.test.ts b/x-pack/plugins/cases/server/connectors/cases/index.test.ts index 5c7b29ef4e704..7b6d244d165b3 100644 --- a/x-pack/plugins/cases/server/connectors/cases/index.test.ts +++ b/x-pack/plugins/cases/server/connectors/cases/index.test.ts @@ -36,6 +36,7 @@ describe('getCasesConnectorType', () => { 'cases:my-owner/updateComment', 'cases:my-owner/deleteComment', 'cases:my-owner/findConfigurations', + 'cases:my-owner/reopenCase', ]); }); @@ -356,6 +357,7 @@ describe('getCasesConnectorType', () => { 'cases:securitySolution/updateComment', 'cases:securitySolution/deleteComment', 'cases:securitySolution/findConfigurations', + 'cases:securitySolution/reopenCase', ]); }); @@ -376,6 +378,7 @@ describe('getCasesConnectorType', () => { 'cases:observability/updateComment', 'cases:observability/deleteComment', 'cases:observability/findConfigurations', + 'cases:observability/reopenCase', ]); }); @@ -396,6 +399,7 @@ describe('getCasesConnectorType', () => { 'cases:securitySolution/updateComment', 'cases:securitySolution/deleteComment', 'cases:securitySolution/findConfigurations', + 'cases:securitySolution/reopenCase', ]); }); }); diff --git a/x-pack/plugins/cases/server/connectors/cases/utils.test.ts b/x-pack/plugins/cases/server/connectors/cases/utils.test.ts index 976a7eadb5aec..55ffb5c7170bd 100644 --- a/x-pack/plugins/cases/server/connectors/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/connectors/cases/utils.test.ts @@ -507,6 +507,7 @@ describe('utils', () => { 'cases:my-owner/updateComment', 'cases:my-owner/deleteComment', 'cases:my-owner/findConfigurations', + 'cases:my-owner/reopenCase', ]); }); }); diff --git a/x-pack/plugins/cases/server/connectors/cases/utils.ts b/x-pack/plugins/cases/server/connectors/cases/utils.ts index a2513027c9cb3..b9cd2982553e3 100644 --- a/x-pack/plugins/cases/server/connectors/cases/utils.ts +++ b/x-pack/plugins/cases/server/connectors/cases/utils.ts @@ -109,7 +109,7 @@ export const buildCustomFieldsForRequest = ( export const constructRequiredKibanaPrivileges = (owner: string): string[] => { /** * Kibana features privileges are defined in - * x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/cases.ts + * x-pack/packages/security/authorization_core/src/privileges/feature_privilege_builder/cases.ts */ return [ `cases:${owner}/createCase`, @@ -120,5 +120,6 @@ export const constructRequiredKibanaPrivileges = (owner: string): string[] => { `cases:${owner}/updateComment`, `cases:${owner}/deleteComment`, `cases:${owner}/findConfigurations`, + `cases:${owner}/reopenCase`, ]; }; diff --git a/x-pack/plugins/cases/server/features/constants.ts b/x-pack/plugins/cases/server/features/constants.ts new file mode 100644 index 0000000000000..fb0a0f4554dee --- /dev/null +++ b/x-pack/plugins/cases/server/features/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Unique sub privilege ids for cases. + * @description When upgrading (creating new versions), the sub-privileges + * do not need to be versioned as they are appended to the top level privilege id which is the only id + * that will need to be versioned + */ + +export const CASES_DELETE_SUB_PRIVILEGE_ID = 'cases_delete'; +export const CASES_SETTINGS_SUB_PRIVILEGE_ID = 'cases_settings'; +export const CASES_CREATE_COMMENT_SUB_PRIVILEGE_ID = 'create_comment'; +export const CASES_REOPEN_SUB_PRIVILEGE_ID = 'case_reopen'; diff --git a/x-pack/plugins/cases/server/features/index.ts b/x-pack/plugins/cases/server/features/index.ts new file mode 100644 index 0000000000000..afa3dfab9b311 --- /dev/null +++ b/x-pack/plugins/cases/server/features/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { KibanaFeatureConfig } from '@kbn/features-plugin/common'; +import { getV1 } from './v1'; +import { getV2 } from './v2'; + +export const getCasesKibanaFeatures = (): { + v1: KibanaFeatureConfig; + v2: KibanaFeatureConfig; +} => ({ v1: getV1(), v2: getV2() }); diff --git a/x-pack/plugins/cases/server/features.ts b/x-pack/plugins/cases/server/features/v1.ts similarity index 67% rename from x-pack/plugins/cases/server/features.ts rename to x-pack/plugins/cases/server/features/v1.ts index f8f162b2ae3dc..25a43434f3723 100644 --- a/x-pack/plugins/cases/server/features.ts +++ b/x-pack/plugins/cases/server/features/v1.ts @@ -12,8 +12,9 @@ import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/s import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; import { KibanaFeatureScope } from '@kbn/features-plugin/common'; -import { APP_ID, FEATURE_ID } from '../common/constants'; -import { createUICapabilities, getApiTags } from '../common'; +import { APP_ID, FEATURE_ID, FEATURE_ID_V2 } from '../../common/constants'; +import { createUICapabilities, getApiTags } from '../../common'; +import { CASES_DELETE_SUB_PRIVILEGE_ID, CASES_SETTINGS_SUB_PRIVILEGE_ID } from './constants'; /** * The order of appearance in the feature privilege page @@ -23,14 +24,24 @@ import { createUICapabilities, getApiTags } from '../common'; const FEATURE_ORDER = 3100; -export const getCasesKibanaFeature = (): KibanaFeatureConfig => { +export const getV1 = (): KibanaFeatureConfig => { const capabilities = createUICapabilities(); const apiTags = getApiTags(APP_ID); return { + deprecated: { + notice: i18n.translate('xpack.cases.features.casesFeature.deprecationMessage', { + defaultMessage: + 'The {currentId} permissions are deprecated, please see {casesFeatureIdV2}.', + values: { + currentId: FEATURE_ID, + casesFeatureIdV2: FEATURE_ID_V2, + }, + }), + }, id: FEATURE_ID, - name: i18n.translate('xpack.cases.features.casesFeatureName', { - defaultMessage: 'Cases', + name: i18n.translate('xpack.cases.features.casesFeatureNameDeprecated', { + defaultMessage: 'Cases (Deprecated)', }), category: DEFAULT_APP_CATEGORIES.management, scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], @@ -42,12 +53,14 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { cases: [APP_ID], privileges: { all: { - api: apiTags.all, + api: [...apiTags.all, ...apiTags.createComment], cases: { create: [APP_ID], read: [APP_ID], update: [APP_ID], push: [APP_ID], + createComment: [APP_ID], + reopenCase: [APP_ID], }, management: { insightsAndAlerting: [APP_ID], @@ -57,6 +70,15 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { read: [...filesSavedObjectTypes], }, ui: capabilities.all, + replacedBy: { + default: [{ feature: FEATURE_ID_V2, privileges: ['all'] }], + minimal: [ + { + feature: FEATURE_ID_V2, + privileges: ['minimal_all', 'create_comment', 'case_reopen'], + }, + ], + }, }, read: { api: apiTags.read, @@ -71,6 +93,10 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { read: [...filesSavedObjectTypes], }, ui: capabilities.read, + replacedBy: { + default: [{ feature: FEATURE_ID_V2, privileges: ['read'] }], + minimal: [{ feature: FEATURE_ID_V2, privileges: ['minimal_read'] }], + }, }, }, subFeatures: [ @@ -84,7 +110,7 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { privileges: [ { api: apiTags.delete, - id: 'cases_delete', + id: CASES_DELETE_SUB_PRIVILEGE_ID, name: i18n.translate('xpack.cases.features.deleteSubFeatureDetails', { defaultMessage: 'Delete cases and comments', }), @@ -97,6 +123,9 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { delete: [APP_ID], }, ui: capabilities.delete, + replacedBy: [ + { feature: FEATURE_ID_V2, privileges: [CASES_DELETE_SUB_PRIVILEGE_ID] }, + ], }, ], }, @@ -111,7 +140,7 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { groupType: 'independent', privileges: [ { - id: 'cases_settings', + id: CASES_SETTINGS_SUB_PRIVILEGE_ID, name: i18n.translate('xpack.cases.features.casesSettingsSubFeatureDetails', { defaultMessage: 'Edit case settings', }), @@ -124,6 +153,9 @@ export const getCasesKibanaFeature = (): KibanaFeatureConfig => { settings: [APP_ID], }, ui: capabilities.settings, + replacedBy: [ + { feature: FEATURE_ID_V2, privileges: [CASES_SETTINGS_SUB_PRIVILEGE_ID] }, + ], }, ], }, diff --git a/x-pack/plugins/cases/server/features/v2.ts b/x-pack/plugins/cases/server/features/v2.ts new file mode 100644 index 0000000000000..fca97303f02ab --- /dev/null +++ b/x-pack/plugins/cases/server/features/v2.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 { i18n } from '@kbn/i18n'; + +import type { KibanaFeatureConfig } from '@kbn/features-plugin/common'; +import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects'; +import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; + +import { KibanaFeatureScope } from '@kbn/features-plugin/common'; +import { APP_ID, FEATURE_ID_V2 } from '../../common/constants'; +import { createUICapabilities, getApiTags } from '../../common'; +import { + CASES_DELETE_SUB_PRIVILEGE_ID, + CASES_SETTINGS_SUB_PRIVILEGE_ID, + CASES_CREATE_COMMENT_SUB_PRIVILEGE_ID, + CASES_REOPEN_SUB_PRIVILEGE_ID, +} from './constants'; + +/** + * The order of appearance in the feature privilege page + * under the management section. Cases should be under + * the Actions and Connectors feature + */ + +const FEATURE_ORDER = 3100; + +export const getV2 = (): KibanaFeatureConfig => { + const capabilities = createUICapabilities(); + const apiTags = getApiTags(APP_ID); + + return { + id: FEATURE_ID_V2, + name: i18n.translate('xpack.cases.features.casesFeatureName', { + defaultMessage: 'Cases', + }), + category: DEFAULT_APP_CATEGORIES.management, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [], + order: FEATURE_ORDER, + management: { + insightsAndAlerting: [APP_ID], + }, + cases: [APP_ID], + privileges: { + all: { + api: apiTags.all, + cases: { + create: [APP_ID], + read: [APP_ID], + update: [APP_ID], + push: [APP_ID], + }, + management: { + insightsAndAlerting: [APP_ID], + }, + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + ui: capabilities.all, + }, + read: { + api: apiTags.read, + cases: { + read: [APP_ID], + }, + management: { + insightsAndAlerting: [APP_ID], + }, + savedObject: { + all: [], + read: [...filesSavedObjectTypes], + }, + ui: capabilities.read, + }, + }, + subFeatures: [ + { + name: i18n.translate('xpack.cases.features.deleteSubFeatureName', { + defaultMessage: 'Delete', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: apiTags.delete, + id: CASES_DELETE_SUB_PRIVILEGE_ID, + name: i18n.translate('xpack.cases.features.deleteSubFeatureDetails', { + defaultMessage: 'Delete cases and comments', + }), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + delete: [APP_ID], + }, + ui: capabilities.delete, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.cases.features.casesSettingsSubFeatureName', { + defaultMessage: 'Case settings', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: CASES_SETTINGS_SUB_PRIVILEGE_ID, + name: i18n.translate('xpack.cases.features.casesSettingsSubFeatureDetails', { + defaultMessage: 'Edit case settings', + }), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + settings: [APP_ID], + }, + ui: capabilities.settings, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.cases.features.addCommentsSubFeatureName', { + defaultMessage: 'Create comments & attachments', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: apiTags.createComment, + id: CASES_CREATE_COMMENT_SUB_PRIVILEGE_ID, + name: i18n.translate('xpack.cases.features.addCommentsSubFeatureDetails', { + defaultMessage: 'Add comments to cases', + }), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + createComment: [APP_ID], + }, + ui: capabilities.createComment, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.cases.features.reopenCaseSubFeatureName', { + defaultMessage: 'Re-open', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: CASES_REOPEN_SUB_PRIVILEGE_ID, + name: i18n.translate('xpack.cases.features.reopenCaseSubFeatureDetails', { + defaultMessage: 'Re-open closed cases', + }), + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + cases: { + reopenCase: [APP_ID], + }, + ui: capabilities.reopenCase, + }, + ], + }, + ], + }, + ], + }; +}; diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index b40089ff75050..dfd4c013f0d58 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -30,7 +30,7 @@ import type { CasesServerStartDependencies, } from './types'; import { CasesClientFactory } from './client/factory'; -import { getCasesKibanaFeature } from './features'; +import { getCasesKibanaFeatures } from './features'; import { registerRoutes } from './routes/api/register_routes'; import { getExternalRoutes } from './routes/api/get_external_routes'; import { createCasesTelemetry, scheduleCasesTelemetryTask } from './telemetry'; @@ -92,7 +92,11 @@ export class CasePlugin this.lensEmbeddableFactory = plugins.lens.lensEmbeddableFactory; if (this.caseConfig.stack.enabled) { - plugins.features.registerKibanaFeature(getCasesKibanaFeature()); + // V1 is deprecated, but has to be maintained for the time being + // https://github.com/elastic/kibana/pull/186800#issue-2369812818 + const casesFeatures = getCasesKibanaFeatures(); + plugins.features.registerKibanaFeature(casesFeatures.v1); + plugins.features.registerKibanaFeature(casesFeatures.v2); } registerSavedObjects({ diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index 188fade8dd2cb..1939d0b5e4e49 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -188,6 +188,7 @@ export interface FeatureKibanaPrivileges { read?: readonly string[]; /** * List of case owners which users should have update access to when granted this privilege. + * This privilege does NOT provide access to re-opening a case. Please see `reopenCase` for said functionality. * @example * ```ts * { @@ -216,6 +217,26 @@ export interface FeatureKibanaPrivileges { * ``` */ settings?: readonly string[]; + /** + * List of case owners whose users should have createComment access when granted this privilege. + * @example + * ```ts + * { + * createComment: ['securitySolution'] + * } + * ``` + */ + createComment?: readonly string[]; + /** + * List of case owners whose users should have reopenCase access when granted this privilege. + * @example + * ```ts + * { + * reopenCase: ['securitySolution'] + * } + * ``` + */ + reopenCase?: readonly string[]; }; /** 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 c91244e2f1d9d..b8df9e9c2117b 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 @@ -557,9 +557,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, @@ -716,9 +718,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, @@ -1050,9 +1054,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, @@ -1190,9 +1196,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, @@ -1349,9 +1357,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, @@ -1683,9 +1693,11 @@ Array [ "cases": Object { "all": Array [], "create": Array [], + "createComment": Array [], "delete": Array [], "push": Array [], "read": Array [], + "reopenCase": Array [], "settings": Array [], "update": Array [], }, 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 58a39c85bf9e9..c7d501bb17cf8 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 @@ -78,6 +78,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -148,6 +150,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -217,6 +221,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -288,6 +294,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -329,6 +337,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -391,6 +401,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-sub-type'], }, @@ -438,6 +450,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -506,6 +520,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -568,6 +584,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-sub-type'], }, @@ -615,6 +633,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -683,6 +703,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -746,6 +768,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-sub-type'], }, @@ -796,6 +820,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type', 'cases-delete-sub-type'], push: ['cases-push-type', 'cases-push-sub-type'], settings: ['cases-settings-type', 'cases-settings-sub-type'], + createComment: ['cases-create-comment-type', 'cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-type', 'cases-reopen-sub-type'], }, ui: ['ui-action', 'ui-sub-type'], }, @@ -832,6 +858,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-action', 'ui-sub-type'], }, @@ -875,6 +903,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -980,6 +1010,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -1015,6 +1047,8 @@ describe('featurePrivilegeIterator', () => { delete: [], push: [], settings: [], + createComment: [], + reopenCase: [], }, ui: ['ui-action'], }, @@ -1056,6 +1090,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -1119,6 +1155,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-sub-type'], }, @@ -1169,6 +1207,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type', 'cases-delete-sub-type'], push: ['cases-push-type', 'cases-push-sub-type'], settings: ['cases-settings-type', 'cases-settings-sub-type'], + createComment: ['cases-create-comment-type', 'cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-type', 'cases-reopen-sub-type'], }, ui: ['ui-action', 'ui-sub-type'], }, @@ -1362,6 +1402,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-sub-type'], }, @@ -1412,6 +1454,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-sub-type'], }, @@ -1448,6 +1492,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-sub-type'], push: ['cases-push-sub-type'], settings: ['cases-settings-sub-type'], + createComment: ['cases-create-comment-sub-type'], + reopenCase: ['cases-reopen-sub-type'], }, ui: ['ui-sub-type'], }, @@ -1489,6 +1535,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -1580,6 +1628,8 @@ describe('featurePrivilegeIterator', () => { delete: ['cases-delete-type'], push: ['cases-push-type'], settings: ['cases-settings-type'], + createComment: ['cases-create-comment-type'], + reopenCase: ['cases-reopen-type'], }, ui: ['ui-action'], }, @@ -1615,6 +1665,8 @@ describe('featurePrivilegeIterator', () => { delete: [], push: [], settings: [], + createComment: [], + reopenCase: [], }, 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 0d1dc8e3ab788..a9d7336ea0a22 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 @@ -151,6 +151,14 @@ function mergeWithSubFeatures( mergedConfig.cases?.settings ?? [], subFeaturePrivilege.cases?.settings ?? [] ), + createComment: mergeArrays( + mergedConfig.cases?.createComment ?? [], + subFeaturePrivilege.cases?.createComment ?? [] + ), + reopenCase: mergeArrays( + mergedConfig.cases?.reopenCase ?? [], + subFeaturePrivilege.cases?.reopenCase ?? [] + ), }; } return mergedConfig; diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 581fdc1037e2a..ce444c41e477d 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -83,6 +83,8 @@ const casesSchemaObject = schema.maybe( delete: schema.maybe(casesSchema), push: schema.maybe(casesSchema), settings: schema.maybe(casesSchema), + createComment: schema.maybe(casesSchema), + reopenCase: schema.maybe(casesSchema), }) ); diff --git a/x-pack/plugins/ml/public/alerting/anomaly_detection_alerts_table/register_alerts_table_configuration.tsx b/x-pack/plugins/ml/public/alerting/anomaly_detection_alerts_table/register_alerts_table_configuration.tsx index 25ffef0456e42..9154a2c77bf4a 100644 --- a/x-pack/plugins/ml/public/alerting/anomaly_detection_alerts_table/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/ml/public/alerting/anomaly_detection_alerts_table/register_alerts_table_configuration.tsx @@ -24,7 +24,7 @@ import { ALERT_STATUS, } from '@kbn/rule-data-utils'; import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; -import { APP_ID as CASE_APP_ID, FEATURE_ID as CASE_GENERAL_ID } from '@kbn/cases-plugin/common'; +import { APP_ID as CASE_APP_ID, FEATURE_ID_V2 as CASE_GENERAL_ID } from '@kbn/cases-plugin/common'; import { MANAGEMENT_APP_ID } from '@kbn/deeplinks-management/constants'; import { getAlertFlyout } from './use_alerts_flyout'; import { diff --git a/x-pack/plugins/observability_solution/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx b/x-pack/plugins/observability_solution/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx index 1d42716bf405d..011fb93553ac4 100644 --- a/x-pack/plugins/observability_solution/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx +++ b/x-pack/plugins/observability_solution/exploratory_view/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx @@ -120,6 +120,8 @@ describe('AddToCaseAction', function () { push: false, connectors: false, settings: false, + createComment: false, + reopenCase: false, }, }) ); diff --git a/x-pack/plugins/observability_solution/observability/common/index.ts b/x-pack/plugins/observability_solution/observability/common/index.ts index 3dc44c5ac02aa..6ffad0b2ed436 100644 --- a/x-pack/plugins/observability_solution/observability/common/index.ts +++ b/x-pack/plugins/observability_solution/observability/common/index.ts @@ -63,7 +63,9 @@ export { getProbabilityFromProgressiveLoadingQuality, } from './progressive_loading'; +/** @deprecated deprecated in 8.17. Please use casesFeatureIdV2 instead */ export const casesFeatureId = 'observabilityCases'; +export const casesFeatureIdV2 = 'observabilityCasesV2'; export const sloFeatureId = 'slo'; // The ID of the observability app. Should more appropriately be called // 'observability' but it's used in telemetry by applicationUsage so we don't diff --git a/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx b/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx index 071b75ab89632..cf0c4aa3c8b60 100644 --- a/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx +++ b/x-pack/plugins/observability_solution/observability/public/pages/alerts/components/alert_actions.tsx @@ -159,7 +159,7 @@ export function AlertActions({ ); const actionsMenuItems = [ - ...(userCasesPermissions.create && userCasesPermissions.read + ...(userCasesPermissions.createComment && userCasesPermissions.read ? [ ({ + deprecated: { + // TODO: Add docLinks to link to documentation about the deprecation + notice: i18n.translate( + 'xpack.observability.featureRegistry.linkObservabilityTitle.deprecationMessage', + { + defaultMessage: + 'The {currentId} permissions are deprecated, please see {casesFeatureIdV2}.', + values: { + currentId: casesFeatureId, + casesFeatureIdV2, + }, + } + ), + }, + id: casesFeatureId, + name: i18n.translate('xpack.observability.featureRegistry.linkObservabilityTitleDeprecated', { + defaultMessage: 'Cases (Deprecated)', + }), + order: 1100, + category: DEFAULT_APP_CATEGORIES.observability, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: [observabilityFeatureId], + privileges: { + all: { + api: [...casesApiTags.all, ...casesApiTags.createComment], + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: { + create: [observabilityFeatureId], + read: [observabilityFeatureId], + update: [observabilityFeatureId], + push: [observabilityFeatureId], + createComment: [observabilityFeatureId], + reopenCase: [observabilityFeatureId], + }, + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + ui: casesCapabilities.all, + replacedBy: { + default: [{ feature: casesFeatureIdV2, privileges: ['all'] }], + minimal: [ + { + feature: casesFeatureIdV2, + privileges: ['minimal_all', 'create_comment', 'case_reopen'], + }, + ], + }, + }, + read: { + api: casesApiTags.read, + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: { + read: [observabilityFeatureId], + }, + savedObject: { + all: [], + read: [...filesSavedObjectTypes], + }, + ui: casesCapabilities.read, + replacedBy: { + default: [{ feature: casesFeatureIdV2, privileges: ['read'] }], + minimal: [{ feature: casesFeatureIdV2, privileges: ['minimal_read'] }], + }, + }, + }, + subFeatures: [ + { + name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureName', { + defaultMessage: 'Delete', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: casesApiTags.delete, + id: 'cases_delete', + name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureDetails', { + defaultMessage: 'Delete cases and comments', + }), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + delete: [observabilityFeatureId], + }, + ui: casesCapabilities.delete, + replacedBy: [{ feature: casesFeatureIdV2, privileges: ['cases_delete'] }], + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.observability.featureRegistry.casesSettingsSubFeatureName', { + defaultMessage: 'Case settings', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cases_settings', + name: i18n.translate( + 'xpack.observability.featureRegistry.casesSettingsSubFeatureDetails', + { + defaultMessage: 'Edit case settings', + } + ), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + settings: [observabilityFeatureId], + }, + ui: casesCapabilities.settings, + replacedBy: [{ feature: casesFeatureIdV2, privileges: ['cases_settings'] }], + }, + ], + }, + ], + }, + ], +}); diff --git a/x-pack/plugins/observability_solution/observability/server/features/cases_v2.ts b/x-pack/plugins/observability_solution/observability/server/features/cases_v2.ts new file mode 100644 index 0000000000000..52b501a62bb2e --- /dev/null +++ b/x-pack/plugins/observability_solution/observability/server/features/cases_v2.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 { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; +import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects'; +import { i18n } from '@kbn/i18n'; +import { KibanaFeatureConfig, KibanaFeatureScope } from '@kbn/features-plugin/common'; +import { CasesUiCapabilities, CasesApiTags } from '@kbn/cases-plugin/common'; +import { casesFeatureIdV2, casesFeatureId, observabilityFeatureId } from '../../common'; + +export const getCasesFeatureV2 = ( + casesCapabilities: CasesUiCapabilities, + casesApiTags: CasesApiTags +): KibanaFeatureConfig => ({ + id: casesFeatureIdV2, + name: i18n.translate('xpack.observability.featureRegistry.linkObservabilityTitle', { + defaultMessage: 'Cases', + }), + order: 1100, + category: DEFAULT_APP_CATEGORIES.observability, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: [observabilityFeatureId], + privileges: { + all: { + api: casesApiTags.all, + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: { + create: [observabilityFeatureId], + read: [observabilityFeatureId], + update: [observabilityFeatureId], + push: [observabilityFeatureId], + }, + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + ui: casesCapabilities.all, + }, + read: { + api: casesApiTags.read, + app: [casesFeatureId, 'kibana'], + catalogue: [observabilityFeatureId], + cases: { + read: [observabilityFeatureId], + }, + savedObject: { + all: [], + read: [...filesSavedObjectTypes], + }, + ui: casesCapabilities.read, + }, + }, + subFeatures: [ + { + name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureName', { + defaultMessage: 'Delete', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: casesApiTags.delete, + id: 'cases_delete', + name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureDetails', { + defaultMessage: 'Delete cases and comments', + }), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + delete: [observabilityFeatureId], + }, + ui: casesCapabilities.delete, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.observability.featureRegistry.casesSettingsSubFeatureName', { + defaultMessage: 'Case settings', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cases_settings', + name: i18n.translate( + 'xpack.observability.featureRegistry.casesSettingsSubFeatureDetails', + { + defaultMessage: 'Edit case settings', + } + ), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + settings: [observabilityFeatureId], + }, + ui: casesCapabilities.settings, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.observability.featureRegistry.addCommentsSubFeatureName', { + defaultMessage: 'Create comments & attachments', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + api: casesApiTags.createComment, + id: 'create_comment', + name: i18n.translate( + 'xpack.observability.featureRegistry.addCommentsSubFeatureDetails', + { + defaultMessage: 'Add comments to cases', + } + ), + includeIn: 'all', + savedObject: { + all: [...filesSavedObjectTypes], + read: [...filesSavedObjectTypes], + }, + cases: { + createComment: [observabilityFeatureId], + }, + ui: casesCapabilities.createComment, + }, + ], + }, + ], + }, + { + name: i18n.translate('xpack.observability.featureRegistry.reopenCaseSubFeatureName', { + defaultMessage: 'Re-open', + }), + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'case_reopen', + name: i18n.translate( + 'xpack.observability.featureRegistry.reopenCaseSubFeatureDetails', + { + defaultMessage: 'Re-open closed cases', + } + ), + includeIn: 'all', + savedObject: { + all: [], + read: [], + }, + cases: { + reopenCase: [observabilityFeatureId], + }, + ui: casesCapabilities.reopenCase, + }, + ], + }, + ], + }, + ], +}); diff --git a/x-pack/plugins/observability_solution/observability/server/plugin.ts b/x-pack/plugins/observability_solution/observability/server/plugin.ts index 7f9a37a5a26c4..b98fe316c712e 100644 --- a/x-pack/plugins/observability_solution/observability/server/plugin.ts +++ b/x-pack/plugins/observability_solution/observability/server/plugin.ts @@ -21,7 +21,6 @@ import { } from '@kbn/core/server'; import { LogsExplorerLocatorParams, LOGS_EXPLORER_LOCATOR_ID } from '@kbn/deeplinks-observability'; import { FeaturesPluginSetup } from '@kbn/features-plugin/server'; -import { hiddenTypes as filesSavedObjectTypes } from '@kbn/files-plugin/server/saved_objects'; import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server'; import { i18n } from '@kbn/i18n'; import { @@ -41,7 +40,7 @@ import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; import { KibanaFeatureScope } from '@kbn/features-plugin/common'; import { ObservabilityConfig } from '.'; -import { casesFeatureId, observabilityFeatureId } from '../common'; +import { observabilityFeatureId } from '../common'; import { kubernetesGuideConfig, kubernetesGuideId, @@ -58,6 +57,8 @@ import { registerRoutes } from './routes/register_routes'; import { threshold } from './saved_objects/threshold'; import { AlertDetailsContextualInsightsService } from './services'; import { uiSettings } from './ui_settings'; +import { getCasesFeature } from './features/cases_v1'; +import { getCasesFeatureV2 } from './features/cases_v2'; export type ObservabilityPluginSetup = ReturnType; @@ -110,112 +111,8 @@ export class ObservabilityPlugin implements Plugin { const alertDetailsContextualInsightsService = new AlertDetailsContextualInsightsService(); - plugins.features.registerKibanaFeature({ - id: casesFeatureId, - name: i18n.translate('xpack.observability.featureRegistry.linkObservabilityTitle', { - defaultMessage: 'Cases', - }), - order: 1100, - category: DEFAULT_APP_CATEGORIES.observability, - scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], - app: [casesFeatureId, 'kibana'], - catalogue: [observabilityFeatureId], - cases: [observabilityFeatureId], - privileges: { - all: { - api: casesApiTags.all, - app: [casesFeatureId, 'kibana'], - catalogue: [observabilityFeatureId], - cases: { - create: [observabilityFeatureId], - read: [observabilityFeatureId], - update: [observabilityFeatureId], - push: [observabilityFeatureId], - }, - savedObject: { - all: [...filesSavedObjectTypes], - read: [...filesSavedObjectTypes], - }, - ui: casesCapabilities.all, - }, - read: { - api: casesApiTags.read, - app: [casesFeatureId, 'kibana'], - catalogue: [observabilityFeatureId], - cases: { - read: [observabilityFeatureId], - }, - savedObject: { - all: [], - read: [...filesSavedObjectTypes], - }, - ui: casesCapabilities.read, - }, - }, - subFeatures: [ - { - name: i18n.translate('xpack.observability.featureRegistry.deleteSubFeatureName', { - defaultMessage: 'Delete', - }), - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - { - api: casesApiTags.delete, - id: 'cases_delete', - name: i18n.translate( - 'xpack.observability.featureRegistry.deleteSubFeatureDetails', - { - defaultMessage: 'Delete cases and comments', - } - ), - includeIn: 'all', - savedObject: { - all: [...filesSavedObjectTypes], - read: [...filesSavedObjectTypes], - }, - cases: { - delete: [observabilityFeatureId], - }, - ui: casesCapabilities.delete, - }, - ], - }, - ], - }, - { - name: i18n.translate('xpack.observability.featureRegistry.casesSettingsSubFeatureName', { - defaultMessage: 'Case settings', - }), - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - { - id: 'cases_settings', - name: i18n.translate( - 'xpack.observability.featureRegistry.casesSettingsSubFeatureDetails', - { - defaultMessage: 'Edit case settings', - } - ), - includeIn: 'all', - savedObject: { - all: [...filesSavedObjectTypes], - read: [...filesSavedObjectTypes], - }, - cases: { - settings: [observabilityFeatureId], - }, - ui: casesCapabilities.settings, - }, - ], - }, - ], - }, - ], - }); + plugins.features.registerKibanaFeature(getCasesFeature(casesCapabilities, casesApiTags)); + plugins.features.registerKibanaFeature(getCasesFeatureV2(casesCapabilities, casesApiTags)); let annotationsApiPromise: Promise | undefined; diff --git a/x-pack/plugins/observability_solution/observability_shared/common/index.ts b/x-pack/plugins/observability_solution/observability_shared/common/index.ts index b4b7731d166b7..f483bcc5dc269 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/index.ts @@ -8,7 +8,7 @@ import { AlertConsumers } from '@kbn/rule-data-utils'; export const observabilityFeatureId = 'observability'; export const observabilityAppId = 'observability-overview'; -export const casesFeatureId = 'observabilityCases'; +export const casesFeatureId = 'observabilityCasesV2'; export const sloFeatureId = 'slo'; // SLO alerts table in slo detail page diff --git a/x-pack/plugins/observability_solution/observability_shared/public/utils/cases_permissions.ts b/x-pack/plugins/observability_solution/observability_shared/public/utils/cases_permissions.ts index 0ceea46ad0d38..0b3699e49b40c 100644 --- a/x-pack/plugins/observability_solution/observability_shared/public/utils/cases_permissions.ts +++ b/x-pack/plugins/observability_solution/observability_shared/public/utils/cases_permissions.ts @@ -14,6 +14,8 @@ export const noCasesPermissions = () => ({ push: false, connectors: false, settings: false, + createComment: false, + reopenCase: false, }); export const allCasesPermissions = () => ({ @@ -25,4 +27,6 @@ export const allCasesPermissions = () => ({ push: true, connectors: true, settings: true, + createComment: true, + reopenCase: true, }); diff --git a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts index 6e3f6751d11dc..49cb34ccdc09e 100644 --- a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts @@ -94,7 +94,7 @@ const roles = [ applications: [ { application: 'kibana-.kibana', - privileges: ['feature_securitySolutionCases.a;;'], + privileges: ['feature_securitySolutionCasesV2.a;;'], resources: ['*'], }, ], @@ -184,7 +184,7 @@ const roles = [ applications: [ { application: 'kibana-.kibana', - privileges: ['feature_securitySolutionCases.a;;'], + privileges: ['feature_securitySolutionCasesV2.a;;'], resources: ['space:default'], }, ], diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 137afe7ba9112..b366a0e555357 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -21,7 +21,7 @@ export const APP_ID = 'securitySolution' as const; export const APP_UI_ID = 'securitySolutionUI' as const; export const ASSISTANT_FEATURE_ID = 'securitySolutionAssistant' as const; export const ATTACK_DISCOVERY_FEATURE_ID = 'securitySolutionAttackDiscovery' as const; -export const CASES_FEATURE_ID = 'securitySolutionCases' as const; +export const CASES_FEATURE_ID = 'securitySolutionCasesV2' as const; export const SERVER_APP_ID = 'siem' as const; export const APP_NAME = 'Security' as const; export const APP_ICON = 'securityAnalyticsApp' as const; diff --git a/x-pack/plugins/security_solution/common/test/ess_roles.json b/x-pack/plugins/security_solution/common/test/ess_roles.json index 94bd3d57a6d7b..361d5d4321756 100644 --- a/x-pack/plugins/security_solution/common/test/ess_roles.json +++ b/x-pack/plugins/security_solution/common/test/ess_roles.json @@ -30,7 +30,7 @@ "siem": ["read", "read_alerts"], "securitySolutionAssistant": ["none"], "securitySolutionAttackDiscovery": ["none"], - "securitySolutionCases": ["read"], + "securitySolutionCasesV2": ["read"], "actions": ["read"], "builtInAlerts": ["read"] }, @@ -79,7 +79,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "actions": ["read"], "builtInAlerts": ["all"] }, @@ -128,7 +128,7 @@ "siem": ["all", "read_alerts", "crud_alerts"], "securitySolutionAssistant": ["all"], "securitySolutionAttackDiscovery": ["all"], - "securitySolutionCases": ["all"], + "securitySolutionCasesV2": ["all"], "builtInAlerts": ["all"] }, "spaces": ["*"], diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx index 9701114915507..af2150b4010d9 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/results/attack_discovery_panel/actions/take_action/index.tsx @@ -33,8 +33,8 @@ const TakeActionComponent: React.FC = ({ attackDiscovery, replacements }) const { cases } = useKibana().services; const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); const canUserCreateAndReadCases = useCallback( - () => userCasesPermissions.create && userCasesPermissions.read, - [userCasesPermissions.create, userCasesPermissions.read] + () => userCasesPermissions.createComment && userCasesPermissions.read, + [userCasesPermissions.createComment, userCasesPermissions.read] ); const { disabled: addToCaseDisabled, onAddToNewCase } = useAddToNewCase({ canUserCreateAndReadCases, diff --git a/x-pack/plugins/security_solution/public/cases_test_utils.ts b/x-pack/plugins/security_solution/public/cases_test_utils.ts index dc70dcab33eaa..f3c356507bcfe 100644 --- a/x-pack/plugins/security_solution/public/cases_test_utils.ts +++ b/x-pack/plugins/security_solution/public/cases_test_utils.ts @@ -15,6 +15,8 @@ export const noCasesCapabilities = (): CasesCapabilities => ({ push_cases: false, cases_connectors: false, cases_settings: false, + case_reopen: false, + create_comment: false, }); export const readCasesCapabilities = (): CasesCapabilities => ({ @@ -25,6 +27,8 @@ export const readCasesCapabilities = (): CasesCapabilities => ({ push_cases: false, cases_connectors: true, cases_settings: false, + case_reopen: false, + create_comment: false, }); export const allCasesCapabilities = (): CasesCapabilities => ({ @@ -35,6 +39,8 @@ export const allCasesCapabilities = (): CasesCapabilities => ({ push_cases: true, cases_connectors: true, cases_settings: true, + case_reopen: true, + create_comment: true, }); export const noCasesPermissions = (): CasesPermissions => ({ @@ -46,6 +52,8 @@ export const noCasesPermissions = (): CasesPermissions => ({ push: false, connectors: false, settings: false, + reopenCase: false, + createComment: false, }); export const readCasesPermissions = (): CasesPermissions => ({ @@ -57,6 +65,8 @@ export const readCasesPermissions = (): CasesPermissions => ({ push: false, connectors: true, settings: false, + reopenCase: false, + createComment: false, }); export const writeCasesPermissions = (): CasesPermissions => ({ @@ -68,6 +78,8 @@ export const writeCasesPermissions = (): CasesPermissions => ({ push: true, connectors: true, settings: true, + reopenCase: true, + createComment: true, }); export const allCasesPermissions = (): CasesPermissions => ({ @@ -79,4 +91,6 @@ export const allCasesPermissions = (): CasesPermissions => ({ push: true, connectors: true, settings: true, + reopenCase: true, + createComment: true, }); diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx index aa11ced2603a9..c07bbd651316a 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_existing_case.tsx @@ -59,7 +59,7 @@ export const useAddToExistingCase = ({ disabled: lensAttributes == null || timeRange == null || - !userCasesPermissions.create || + !userCasesPermissions.createComment || !userCasesPermissions.read, }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx index c2ac628000fa7..7803e27b2453f 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.tsx @@ -60,7 +60,7 @@ export const useAddToNewCase = ({ disabled: lensAttributes == null || timeRange == null || - !userCasesPermissions.create || + !userCasesPermissions.createComment || !userCasesPermissions.read, }; }; diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.tsx b/x-pack/plugins/security_solution/public/common/links/links.test.tsx index c0f8c8cc48da4..c5f05afde9c62 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.test.tsx +++ b/x-pack/plugins/security_solution/public/common/links/links.test.tsx @@ -432,9 +432,9 @@ describe('Security links', () => { describe('hasCapabilities', () => { const siemShow = 'siem.show'; - const createCases = 'securitySolutionCases.create_cases'; - const readCases = 'securitySolutionCases.read_cases'; - const pushCases = 'securitySolutionCases.push_cases'; + const createCases = 'securitySolutionCasesV2.create_cases'; + const readCases = 'securitySolutionCasesV2.read_cases'; + const pushCases = 'securitySolutionCasesV2.push_cases'; it('returns false when capabilities is an empty array', () => { expect(hasCapabilities(createCapabilities(), [])).toBeFalsy(); @@ -461,7 +461,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: true }, - securitySolutionCases: { create_cases: false }, + securitySolutionCasesV2: { create_cases: false }, }), [siemShow, createCases] ) @@ -473,7 +473,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: false }, - securitySolutionCases: { create_cases: true }, + securitySolutionCasesV2: { create_cases: true }, }), [siemShow, createCases] ) @@ -485,7 +485,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: true }, - securitySolutionCases: { create_cases: false }, + securitySolutionCasesV2: { create_cases: false }, }), [readCases, createCases] ) @@ -497,7 +497,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: true }, - securitySolutionCases: { read_cases: true, create_cases: true }, + securitySolutionCasesV2: { read_cases: true, create_cases: true }, }), [[readCases, createCases]] ) @@ -509,7 +509,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: false }, - securitySolutionCases: { read_cases: false, create_cases: true }, + securitySolutionCasesV2: { read_cases: false, create_cases: true }, }), [siemShow, [readCases, createCases]] ) @@ -521,7 +521,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: true }, - securitySolutionCases: { read_cases: false, create_cases: true }, + securitySolutionCasesV2: { read_cases: false, create_cases: true }, }), [siemShow, [readCases, createCases]] ) @@ -533,7 +533,7 @@ describe('Security links', () => { hasCapabilities( createCapabilities({ siem: { show: true }, - securitySolutionCases: { read_cases: false, create_cases: true, push_cases: false }, + securitySolutionCasesV2: { read_cases: false, create_cases: true, push_cases: false }, }), [ [siemShow, pushCases], diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx index bdef9cd84c8f6..fa14fc317a78a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.test.tsx @@ -88,6 +88,8 @@ jest.mock('../../../../common/lib/kibana', () => { update: true, delete: true, push: true, + createComment: true, + reopenCase: true, }), getRuleIdFromEvent: jest.fn(), }, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx index 60a19f005c53e..8ddcd34f092f0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.tsx @@ -142,7 +142,7 @@ export const useAddToCaseActions = ({ const addToCaseActionItems: AlertTableContextMenuItem[] = useMemo(() => { if ( (isActiveTimelines || isInDetections) && - userCasesPermissions.create && + userCasesPermissions.createComment && userCasesPermissions.read && isAlert ) { @@ -169,14 +169,14 @@ export const useAddToCaseActions = ({ } return []; }, [ + isActiveTimelines, + isInDetections, + userCasesPermissions.createComment, + userCasesPermissions.read, + isAlert, ariaLabel, handleAddToExistingCaseClick, handleAddToNewCaseClick, - userCasesPermissions.create, - userCasesPermissions.read, - isInDetections, - isActiveTimelines, - isAlert, ]); return { diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/common.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/common.ts index 64fd3279d18cb..b5c524255509f 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/common.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/common.ts @@ -15,7 +15,7 @@ export const API_AUTH = Object.freeze({ export const COMMON_API_HEADERS = Object.freeze({ 'kbn-xsrf': 'cypress', 'x-elastic-internal-origin': 'security-solution', - 'Elastic-Api-Version': '2023-10-31', + 'elastic-api-version': '2023-10-31', }); export const waitForPageToBeLoaded = () => { diff --git a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx index e785e58435432..fce22635f3f64 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx @@ -95,8 +95,8 @@ const DataQualityComponent: React.FC = () => { const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); const canUserCreateAndReadCases = useCallback( - () => userCasesPermissions.create && userCasesPermissions.read, - [userCasesPermissions.create, userCasesPermissions.read] + () => userCasesPermissions.createComment && userCasesPermissions.read, + [userCasesPermissions.createComment, userCasesPermissions.read] ); const createCaseFlyout = cases.hooks.useCasesAddToNewCaseFlyout({ diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index b20e645d71c2c..b74d0cffdc88d 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -346,7 +346,7 @@ export class Plugin implements IPlugin ({ status: AppStatus.inaccessible, visibleIn: [], diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx index 228c6bc70584c..1816fb47a102a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.test.tsx @@ -87,7 +87,7 @@ describe('TimelineModalHeader', () => { cases: { helpers: { canUseCases: jest.fn().mockReturnValue({ - create: true, + createComment: true, read: true, }), }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.tsx index e30e0c2cf2a10..350ff1ca65cd6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/header/index.tsx @@ -161,7 +161,7 @@ export const TimelineModalHeader = React.memo( isDisabled={isInspectDisabled} /> - {userCasesPermissions.create && userCasesPermissions.read ? ( + {userCasesPermissions.createComment && userCasesPermissions.read ? ( <> diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/endpoint_operations_analyst.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/endpoint_operations_analyst.ts index a1f3585ffcdc7..85cadf5aa65d4 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/endpoint_operations_analyst.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/endpoint_operations_analyst.ts @@ -55,7 +55,7 @@ export const getEndpointOperationsAnalyst: () => Omit = () => { fleet: ['all'], fleetv2: ['all'], osquery: ['all'], - securitySolutionCases: ['all'], + securitySolutionCasesV2: ['all'], builtinAlerts: ['all'], siem: [ 'all', diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/without_response_actions_role.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/without_response_actions_role.ts index 4ed5f91df77dd..d57ca059de994 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/without_response_actions_role.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/roles_users/without_response_actions_role.ts @@ -37,7 +37,7 @@ export const getNoResponseActionsRole: () => Omit = () => ({ advancedSettings: ['all'], dev_tools: ['all'], fleet: ['all'], - generalCases: ['all'], + generalCasesV2: ['all'], indexPatterns: ['all'], osquery: ['all'], savedObjectsManagement: ['all'], diff --git a/x-pack/plugins/security_solution/server/lib/product_features_service/mocks.ts b/x-pack/plugins/security_solution/server/lib/product_features_service/mocks.ts index c2275ebbcee5f..29df069020561 100644 --- a/x-pack/plugins/security_solution/server/lib/product_features_service/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/product_features_service/mocks.ts @@ -26,6 +26,11 @@ jest.mock('@kbn/security-solution-features/product_features', () => ({ baseKibanaSubFeatureIds: [], subFeaturesMap: new Map(), })), + getCasesV2Feature: jest.fn(() => ({ + baseKibanaFeature: {}, + baseKibanaSubFeatureIds: [], + subFeaturesMap: new Map(), + })), getAssistantFeature: jest.fn(() => ({ baseKibanaFeature: {}, baseKibanaSubFeatureIds: [], diff --git a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts index 8d274a30ca3c9..768228f319b24 100644 --- a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts +++ b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.test.ts @@ -44,6 +44,7 @@ jest.mock('@kbn/security-solution-features/product_features', () => ({ getAttackDiscoveryFeature: () => mockGetFeature(), getAssistantFeature: () => mockGetFeature(), getCasesFeature: () => mockGetFeature(), + getCasesV2Feature: () => mockGetFeature(), getSecurityFeature: () => mockGetFeature(), })); @@ -56,8 +57,8 @@ describe('ProductFeaturesService', () => { const experimentalFeatures = {} as ExperimentalFeatures; new ProductFeaturesService(loggerMock.create(), experimentalFeatures); - expect(mockGetFeature).toHaveBeenCalledTimes(4); - expect(MockedProductFeatures).toHaveBeenCalledTimes(4); + expect(mockGetFeature).toHaveBeenCalledTimes(5); + expect(MockedProductFeatures).toHaveBeenCalledTimes(5); }); it('should init all ProductFeatures when initialized', () => { diff --git a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts index 86928ff905545..2901734527a93 100644 --- a/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts +++ b/x-pack/plugins/security_solution/server/lib/product_features_service/product_features_service.ts @@ -20,6 +20,7 @@ import { getAttackDiscoveryFeature, getCasesFeature, getSecurityFeature, + getCasesV2Feature, } from '@kbn/security-solution-features/product_features'; import type { RecursiveReadonly } from '@kbn/utility-types'; import type { ExperimentalFeatures } from '../../../common'; @@ -35,6 +36,7 @@ export const API_ACTION_PREFIX = `${APP_ID}-`; export class ProductFeaturesService { private securityProductFeatures: ProductFeatures; private casesProductFeatures: ProductFeatures; + private casesProductV2Features: ProductFeatures; private securityAssistantProductFeatures: ProductFeatures; private attackDiscoveryProductFeatures: ProductFeatures; private productFeatures?: Set; @@ -59,6 +61,7 @@ export class ProductFeaturesService { apiTags: casesApiTags, savedObjects: { files: filesSavedObjectTypes }, }); + this.casesProductFeatures = new ProductFeatures( this.logger, casesFeature.subFeaturesMap, @@ -66,6 +69,19 @@ export class ProductFeaturesService { casesFeature.baseKibanaSubFeatureIds ); + const casesV2Feature = getCasesV2Feature({ + uiCapabilities: casesUiCapabilities, + apiTags: casesApiTags, + savedObjects: { files: filesSavedObjectTypes }, + }); + + this.casesProductV2Features = new ProductFeatures( + this.logger, + casesV2Feature.subFeaturesMap, + casesV2Feature.baseKibanaFeature, + casesV2Feature.baseKibanaSubFeatureIds + ); + const assistantFeature = getAssistantFeature(this.experimentalFeatures); this.securityAssistantProductFeatures = new ProductFeatures( this.logger, @@ -86,6 +102,7 @@ export class ProductFeaturesService { public init(featuresSetup: FeaturesPluginSetup) { this.securityProductFeatures.init(featuresSetup); this.casesProductFeatures.init(featuresSetup); + this.casesProductV2Features.init(featuresSetup); this.securityAssistantProductFeatures.init(featuresSetup); this.attackDiscoveryProductFeatures.init(featuresSetup); } @@ -96,6 +113,7 @@ export class ProductFeaturesService { const casesProductFeaturesConfig = configurator.cases(); this.casesProductFeatures.setConfig(casesProductFeaturesConfig); + this.casesProductV2Features.setConfig(casesProductFeaturesConfig); const securityAssistantProductFeaturesConfig = configurator.securityAssistant(); this.securityAssistantProductFeatures.setConfig(securityAssistantProductFeaturesConfig); @@ -124,6 +142,7 @@ export class ProductFeaturesService { return ( this.securityProductFeatures.isActionRegistered(action) || this.casesProductFeatures.isActionRegistered(action) || + this.casesProductV2Features.isActionRegistered(action) || this.securityAssistantProductFeatures.isActionRegistered(action) || this.attackDiscoveryProductFeatures.isActionRegistered(action) ); diff --git a/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_existing_case.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_existing_case.test.tsx index 7cf41aac902a6..d498565dd3908 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_existing_case.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_existing_case.test.tsx @@ -26,7 +26,7 @@ describe('AddToExistingCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, @@ -51,7 +51,7 @@ describe('AddToExistingCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, @@ -85,7 +85,7 @@ describe('AddToExistingCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: false, + createComment: false, update: false, }), }, diff --git a/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_new_case.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_new_case.test.tsx index 3baedf85b5b7e..a92a08d10c571 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_new_case.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/cases/components/add_to_new_case.test.tsx @@ -26,7 +26,7 @@ describe('AddToNewCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, @@ -51,7 +51,7 @@ describe('AddToNewCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, @@ -86,7 +86,7 @@ describe('AddToNewCase', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: false, + createComment: false, update: false, }), }, diff --git a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx index a43efebe98391..8e2f5d3d96a25 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx +++ b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.test.tsx @@ -36,7 +36,7 @@ describe('useCasePermission', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, @@ -60,7 +60,7 @@ describe('useCasePermission', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: false, + createComment: false, update: true, }), }, @@ -84,7 +84,7 @@ describe('useCasePermission', () => { helpers: { ...casesServiceMock.helpers, canUseCases: () => ({ - create: true, + createComment: true, update: true, }), }, diff --git a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.ts b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.ts index f1a1079c23af1..89e35b8074811 100644 --- a/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.ts +++ b/x-pack/plugins/threat_intelligence/public/modules/cases/hooks/use_case_permission.ts @@ -24,7 +24,7 @@ export const useCaseDisabled = (indicatorName: string): boolean => { // disable the item if there is no indicator name or if the user doesn't have the right permission // in the case's attachment, the indicator name is the link to open the flyout const invalidIndicatorName: boolean = indicatorName === EMPTY_VALUE; - const hasPermission: boolean = permissions.create && permissions.update; + const hasPermission: boolean = permissions.createComment && permissions.update; return invalidIndicatorName || !hasPermission; }; diff --git a/x-pack/test/api_integration/apis/cases/common/roles.ts b/x-pack/test/api_integration/apis/cases/common/roles.ts index 5c3e7025900fd..21ad6943ba0df 100644 --- a/x-pack/test/api_integration/apis/cases/common/roles.ts +++ b/x-pack/test/api_integration/apis/cases/common/roles.ts @@ -111,6 +111,31 @@ export const secAll: Role = { }, }; +export const secCasesV2All: Role = { + name: 'sec_cases_v2_all_role_api_int', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + feature: { + siem: ['all'], + securitySolutionCasesV2: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['*'], + }, + ], + }, +}; + export const secAllSpace1: Role = { name: 'sec_all_role_space1_api_int', privileges: { @@ -384,6 +409,31 @@ export const casesAll: Role = { }, }; +export const casesV2All: Role = { + name: 'cases_v2_all_role_api_int', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + generalCasesV2: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + }, + ], + }, +}; + export const casesRead: Role = { name: 'cases_read_role_api_int', privileges: { @@ -508,6 +558,31 @@ export const obsCasesAll: Role = { }, }; +export const obsCasesV2All: Role = { + name: 'obs_cases_v2_all_role_api_int', + privileges: { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + observabilityCasesV2: ['all'], + actions: ['all'], + actionsSimulators: ['all'], + }, + }, + ], + }, +}; + export const obsCasesRead: Role = { name: 'obs_cases_read_role_api_int', privileges: { @@ -537,6 +612,7 @@ export const roles = [ secAllCasesOnlyReadDelete, secAllCasesNoDelete, secAll, + secCasesV2All, secAllSpace1, secAllCasesRead, secAllCasesNone, @@ -548,10 +624,12 @@ export const roles = [ casesOnlyReadDelete, casesNoDelete, casesAll, + casesV2All, casesRead, obsCasesOnlyDelete, obsCasesOnlyReadDelete, obsCasesNoDelete, obsCasesAll, + obsCasesV2All, obsCasesRead, ]; diff --git a/x-pack/test/api_integration/apis/cases/common/users.ts b/x-pack/test/api_integration/apis/cases/common/users.ts index 6cf938dcb0740..a64b9767498fb 100644 --- a/x-pack/test/api_integration/apis/cases/common/users.ts +++ b/x-pack/test/api_integration/apis/cases/common/users.ts @@ -8,16 +8,19 @@ import { User } from '../../../../cases_api_integration/common/lib/authentication/types'; import { casesAll, + casesV2All, casesNoDelete, casesOnlyDelete, casesOnlyReadDelete, casesRead, obsCasesAll, + obsCasesV2All, obsCasesNoDelete, obsCasesOnlyDelete, obsCasesOnlyReadDelete, obsCasesRead, secAll, + secCasesV2All, secAllCasesNoDelete, secAllCasesNone, secAllCasesOnlyDelete, @@ -58,6 +61,12 @@ export const secAllUser: User = { roles: [secAll.name], }; +export const secCasesV2AllUser: User = { + username: 'sec_cases_v2_all_user_api_int', + password: 'password', + roles: [secCasesV2All.name], +}; + export const secAllSpace1User: User = { username: 'sec_all_space1_user_api_int', password: 'password', @@ -128,6 +137,12 @@ export const casesAllUser: User = { roles: [casesAll.name], }; +export const casesV2AllUser: User = { + username: 'cases_v2_all_user_api_int', + password: 'password', + roles: [casesV2All.name], +}; + export const casesReadUser: User = { username: 'cases_read_user_api_int', password: 'password', @@ -162,6 +177,12 @@ export const obsCasesAllUser: User = { roles: [obsCasesAll.name], }; +export const obsCasesV2AllUser: User = { + username: 'obs_cases_v2_all_user_api_int', + password: 'password', + roles: [obsCasesV2All.name], +}; + export const obsCasesReadUser: User = { username: 'obs_cases_read_user_api_int', password: 'password', @@ -189,6 +210,7 @@ export const users = [ secAllCasesOnlyReadDeleteUser, secAllCasesNoDeleteUser, secAllUser, + secCasesV2AllUser, secAllSpace1User, secAllCasesReadUser, secAllCasesNoneUser, @@ -200,11 +222,13 @@ export const users = [ casesOnlyReadDeleteUser, casesNoDeleteUser, casesAllUser, + casesV2AllUser, casesReadUser, obsCasesOnlyDeleteUser, obsCasesOnlyReadDeleteUser, obsCasesNoDeleteUser, obsCasesAllUser, + obsCasesV2AllUser, obsCasesReadUser, obsSecCasesAllUser, obsSecCasesReadUser, diff --git a/x-pack/test/api_integration/apis/cases/privileges.ts b/x-pack/test/api_integration/apis/cases/privileges.ts index 96a8970adeeee..53a1767f5c1a7 100644 --- a/x-pack/test/api_integration/apis/cases/privileges.ts +++ b/x-pack/test/api_integration/apis/cases/privileges.ts @@ -7,6 +7,8 @@ import expect from '@kbn/expect'; import { APP_ID as CASES_APP_ID } from '@kbn/cases-plugin/common/constants'; +import { AttachmentType } from '@kbn/cases-plugin/common'; +import { CaseStatuses, UserCommentAttachmentPayload } from '@kbn/cases-plugin/common/types/domain'; import { APP_ID as SECURITY_SOLUTION_APP_ID } from '@kbn/security-solution-plugin/common/constants'; import { observabilityFeatureId as OBSERVABILITY_APP_ID } from '@kbn/observability-plugin/common'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -16,12 +18,16 @@ import { deleteAllCaseItems, deleteCases, getCase, + createComment, + updateCaseStatus, } from '../../../cases_api_integration/common/lib/api'; import { casesAllUser, + casesV2AllUser, casesNoDeleteUser, casesOnlyDeleteUser, obsCasesAllUser, + obsCasesV2AllUser, obsCasesNoDeleteUser, obsCasesOnlyDeleteUser, secAllCasesNoDeleteUser, @@ -29,6 +35,7 @@ import { secAllCasesOnlyDeleteUser, secAllCasesReadUser, secAllUser, + secCasesV2AllUser, secReadCasesAllUser, secReadCasesNoneUser, secReadCasesReadUser, @@ -48,10 +55,13 @@ export default ({ getService }: FtrProviderContext): void => { for (const { user, owner } of [ { user: secAllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: secCasesV2AllUser, owner: SECURITY_SOLUTION_APP_ID }, { user: secReadCasesAllUser, owner: SECURITY_SOLUTION_APP_ID }, { user: casesAllUser, owner: CASES_APP_ID }, + { user: casesV2AllUser, owner: CASES_APP_ID }, { user: casesNoDeleteUser, owner: CASES_APP_ID }, { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesV2AllUser, owner: OBSERVABILITY_APP_ID }, { user: obsCasesNoDeleteUser, owner: OBSERVABILITY_APP_ID }, ]) { it(`User ${user.username} with role(s) ${user.roles.join()} can create a case`, async () => { @@ -68,8 +78,10 @@ export default ({ getService }: FtrProviderContext): void => { { user: secReadCasesReadUser, owner: SECURITY_SOLUTION_APP_ID }, { user: secReadUser, owner: SECURITY_SOLUTION_APP_ID }, { user: casesAllUser, owner: CASES_APP_ID }, + { user: casesV2AllUser, owner: CASES_APP_ID }, { user: casesNoDeleteUser, owner: CASES_APP_ID }, { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesV2AllUser, owner: OBSERVABILITY_APP_ID }, { user: obsCasesNoDeleteUser, owner: OBSERVABILITY_APP_ID }, ]) { it(`User ${user.username} with role(s) ${user.roles.join()} can get a case`, async () => { @@ -125,10 +137,13 @@ export default ({ getService }: FtrProviderContext): void => { for (const { user, owner } of [ { user: secAllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: secCasesV2AllUser, owner: SECURITY_SOLUTION_APP_ID }, { user: secAllCasesOnlyDeleteUser, owner: SECURITY_SOLUTION_APP_ID }, { user: casesAllUser, owner: CASES_APP_ID }, + { user: casesV2AllUser, owner: CASES_APP_ID }, { user: casesOnlyDeleteUser, owner: CASES_APP_ID }, { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesV2AllUser, owner: OBSERVABILITY_APP_ID }, { user: obsCasesOnlyDeleteUser, owner: OBSERVABILITY_APP_ID }, ]) { it(`User ${user.username} with role(s) ${user.roles.join()} can delete a case`, async () => { @@ -160,5 +175,60 @@ export default ({ getService }: FtrProviderContext): void => { }); }); } + + for (const { user, owner } of [ + { user: secAllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: secCasesV2AllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesV2AllUser, owner: OBSERVABILITY_APP_ID }, + { user: casesAllUser, owner: CASES_APP_ID }, + { user: casesV2AllUser, owner: CASES_APP_ID }, + ]) { + it(`User ${user.username} with role(s) ${user.roles.join()} can reopen a case`, async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest({ owner })); + await updateCaseStatus({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + status: 'closed' as CaseStatuses, + version: '2', + expectedHttpCode: 200, + auth: { user, space: null }, + }); + + await updateCaseStatus({ + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + status: 'open' as CaseStatuses, + version: '3', + expectedHttpCode: 200, + auth: { user, space: null }, + }); + }); + } + + for (const { user, owner } of [ + { user: secAllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: secCasesV2AllUser, owner: SECURITY_SOLUTION_APP_ID }, + { user: obsCasesAllUser, owner: OBSERVABILITY_APP_ID }, + { user: obsCasesV2AllUser, owner: OBSERVABILITY_APP_ID }, + { user: casesAllUser, owner: CASES_APP_ID }, + { user: casesV2AllUser, owner: CASES_APP_ID }, + ]) { + it(`User ${user.username} with role(s) ${user.roles.join()} can add comments`, async () => { + const caseInfo = await createCase(supertest, getPostCaseRequest({ owner })); + const comment: UserCommentAttachmentPayload = { + comment: 'test', + owner, + type: AttachmentType.user, + }; + await createComment({ + params: comment, + supertest: supertestWithoutAuth, + caseId: caseInfo.id, + expectedHttpCode: 200, + auth: { user, space: null }, + }); + }); + } }); }; diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 547fd12a54203..4ded1782c9086 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -111,7 +111,7 @@ export default function ({ getService }: FtrProviderContext) { 'guidedOnboardingFeature', 'monitoring', 'observabilityAIAssistant', - 'observabilityCases', + 'observabilityCasesV2', 'savedObjectsManagement', 'savedQueryManagement', 'savedObjectsTagging', @@ -119,7 +119,7 @@ export default function ({ getService }: FtrProviderContext) { 'apm', 'stackAlerts', 'canvas', - 'generalCases', + 'generalCasesV2', 'infrastructure', 'inventory', 'logs', @@ -133,7 +133,7 @@ export default function ({ getService }: FtrProviderContext) { 'slo', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', - 'securitySolutionCases', + 'securitySolutionCasesV2', 'fleet', 'fleetv2', ].sort() @@ -161,7 +161,7 @@ export default function ({ getService }: FtrProviderContext) { 'guidedOnboardingFeature', 'monitoring', 'observabilityAIAssistant', - 'observabilityCases', + 'observabilityCasesV2', 'savedObjectsManagement', 'savedQueryManagement', 'savedObjectsTagging', @@ -169,7 +169,7 @@ export default function ({ getService }: FtrProviderContext) { 'apm', 'stackAlerts', 'canvas', - 'generalCases', + 'generalCasesV2', 'infrastructure', 'inventory', 'logs', @@ -183,7 +183,7 @@ export default function ({ getService }: FtrProviderContext) { 'slo', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', - 'securitySolutionCases', + 'securitySolutionCasesV2', 'fleet', 'fleetv2', ]; diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 1ff986829415b..b269aef6ae1cc 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -30,6 +30,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + generalCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], observabilityCases: [ 'all', 'read', @@ -38,6 +48,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + observabilityCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], observabilityAIAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], slo: ['all', 'read', 'minimal_all', 'minimal_read'], searchInferenceEndpoints: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -89,6 +109,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + securitySolutionCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], logs: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 57a166ef4be9d..a97ee360062c0 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -32,7 +32,9 @@ export default function ({ getService }: FtrProviderContext) { graph: ['all', 'read', 'minimal_all', 'minimal_read'], maps: ['all', 'read', 'minimal_all', 'minimal_read'], generalCases: ['all', 'read', 'minimal_all', 'minimal_read'], + generalCasesV2: ['all', 'read', 'minimal_all', 'minimal_read'], observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read'], + observabilityCasesV2: ['all', 'read', 'minimal_all', 'minimal_read'], observabilityAIAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], slo: ['all', 'read', 'minimal_all', 'minimal_read'], canvas: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -47,6 +49,7 @@ export default function ({ getService }: FtrProviderContext) { securitySolutionAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionAttackDiscovery: ['all', 'read', 'minimal_all', 'minimal_read'], securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read'], + securitySolutionCasesV2: ['all', 'read', 'minimal_all', 'minimal_read'], searchInferenceEndpoints: ['all', 'read', 'minimal_all', 'minimal_read'], fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'], fleet: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -112,6 +115,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + generalCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], observabilityCases: [ 'all', 'read', @@ -120,6 +133,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + observabilityCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], observabilityAIAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], slo: ['all', 'read', 'minimal_all', 'minimal_read'], searchInferenceEndpoints: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -177,6 +200,16 @@ export default function ({ getService }: FtrProviderContext) { 'cases_delete', 'cases_settings', ], + securitySolutionCasesV2: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'cases_delete', + 'cases_settings', + 'create_comment', + 'case_reopen', + ], infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], logs: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], 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 index a39796f1f4448..2a85320d14edf 100644 --- 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 @@ -37,7 +37,7 @@ const secAll: Role = { { feature: { siem: ['all'], - securitySolutionCases: ['all'], + securitySolutionCasesV2: ['all'], actions: ['all'], actionsSimulators: ['all'], }, @@ -68,7 +68,7 @@ const secRead: Role = { { feature: { siem: ['read'], - securitySolutionCases: ['read'], + securitySolutionCasesV2: ['read'], actions: ['all'], actionsSimulators: ['all'], }, diff --git a/x-pack/test/cases_api_integration/common/lib/api/case.ts b/x-pack/test/cases_api_integration/common/lib/api/case.ts index 759e2de460460..9f03a62032c89 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/case.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/case.ts @@ -6,8 +6,12 @@ */ import { CASES_URL } from '@kbn/cases-plugin/common'; -import { Case } from '@kbn/cases-plugin/common/types/domain'; -import { CasePostRequest, CasesFindResponse } from '@kbn/cases-plugin/common/types/api'; +import { Case, CaseStatuses } from '@kbn/cases-plugin/common/types/domain'; +import { + CasePostRequest, + CasesFindResponse, + CasePatchRequest, +} from '@kbn/cases-plugin/common/types/api'; import type SuperTest from 'supertest'; import { ToolingLog } from '@kbn/tooling-log'; import { User } from '../authentication/types'; @@ -91,3 +95,32 @@ export const deleteCases = async ({ return body; }; + +export const updateCaseStatus = async ({ + supertest, + caseId, + version = '2', + status = 'open' as CaseStatuses, + expectedHttpCode = 204, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.Agent; + caseId: string; + version?: string; + status?: CaseStatuses; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}) => { + const updateRequest: CasePatchRequest = { + status, + version, + id: caseId, + }; + + const { body: updatedCase } = await supertest + .patch(`/api/cases/${caseId}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'xxx') + .send(updateRequest); + return updatedCase; +}; diff --git a/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts b/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts index d5969606dc414..a3b8b71d2fc97 100644 --- a/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts +++ b/x-pack/test/cases_api_integration/common/lib/authentication/roles.ts @@ -7,31 +7,28 @@ import { Role } from './types'; +const defaultElasticsearchPrivileges = { + elasticsearch: { + indices: [ + { + names: ['*'], + privileges: ['all'], + }, + ], + }, +}; + export const noKibanaPrivileges: Role = { name: 'no_kibana_privileges', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, }, }; export const noCasesPrivilegesSpace1: Role = { name: 'no_cases_kibana_privileges', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -47,14 +44,7 @@ export const noCasesPrivilegesSpace1: Role = { export const noCasesConnectors: Role = { name: 'no_cases_connectors', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -71,14 +61,7 @@ export const noCasesConnectors: Role = { export const globalRead: Role = { name: 'global_read', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -96,14 +79,7 @@ export const globalRead: Role = { export const testDisabledPluginAll: Role = { name: 'test_disabled_plugin_all', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -121,14 +97,7 @@ export const testDisabledPluginAll: Role = { export const securitySolutionOnlyAll: Role = { name: 'sec_only_all', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -145,14 +114,7 @@ export const securitySolutionOnlyAll: Role = { export const securitySolutionOnlyDelete: Role = { name: 'sec_only_delete', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -169,18 +131,11 @@ export const securitySolutionOnlyDelete: Role = { export const securitySolutionOnlyReadDelete: Role = { name: 'sec_only_read_delete', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { - securitySolutionFixture: ['read', 'cases_delete'], + securitySolutionFixture: ['minimal_read', 'cases_delete'], actions: ['all'], actionsSimulators: ['all'], }, @@ -193,14 +148,58 @@ export const securitySolutionOnlyReadDelete: Role = { export const securitySolutionOnlyNoDelete: Role = { name: 'sec_only_no_delete', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], + ...defaultElasticsearchPrivileges, + kibana: [ + { + feature: { + securitySolutionFixture: ['minimal_all'], + actions: ['all'], + actionsSimulators: ['all'], }, - ], - }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyCreateComment: Role = { + name: 'sec_only_create_comment', + privileges: { + ...defaultElasticsearchPrivileges, + kibana: [ + { + feature: { + securitySolutionFixture: ['create_comment'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyReadCreateComment: Role = { + name: 'sec_only_read_create_comment', + privileges: { + ...defaultElasticsearchPrivileges, + kibana: [ + { + feature: { + securitySolutionFixture: ['minimal_read', 'create_comment'], + actions: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['space1'], + }, + ], + }, +}; + +export const securitySolutionOnlyNoCreateComment: Role = { + name: 'sec_only_no_create_comment', + privileges: { + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -217,14 +216,7 @@ export const securitySolutionOnlyNoDelete: Role = { export const securitySolutionOnlyRead: Role = { name: 'sec_only_read', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -241,14 +233,7 @@ export const securitySolutionOnlyRead: Role = { export const securitySolutionOnlyReadAlerts: Role = { name: 'sec_only_read_alerts', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -282,14 +267,7 @@ export const securitySolutionOnlyReadNoIndexAlerts: Role = { export const observabilityOnlyAll: Role = { name: 'obs_only_all', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -306,14 +284,7 @@ export const observabilityOnlyAll: Role = { export const observabilityOnlyRead: Role = { name: 'obs_only_read', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -353,14 +324,7 @@ export const observabilityOnlyReadAlerts: Role = { export const securitySolutionOnlyAllSpacesRole: Role = { name: 'sec_only_all_spaces', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -377,14 +341,7 @@ export const securitySolutionOnlyAllSpacesRole: Role = { export const onlyActions: Role = { name: 'only_actions', privileges: { - elasticsearch: { - indices: [ - { - names: ['*'], - privileges: ['all'], - }, - ], - }, + ...defaultElasticsearchPrivileges, kibana: [ { feature: { @@ -408,6 +365,9 @@ export const roles = [ securitySolutionOnlyDelete, securitySolutionOnlyReadDelete, securitySolutionOnlyNoDelete, + securitySolutionOnlyCreateComment, + securitySolutionOnlyReadCreateComment, + securitySolutionOnlyNoCreateComment, observabilityOnlyAll, observabilityOnlyRead, observabilityOnlyReadAlerts, diff --git a/x-pack/test/cases_api_integration/common/lib/authentication/users.ts b/x-pack/test/cases_api_integration/common/lib/authentication/users.ts index 9bf90665eb181..01489d878526c 100644 --- a/x-pack/test/cases_api_integration/common/lib/authentication/users.ts +++ b/x-pack/test/cases_api_integration/common/lib/authentication/users.ts @@ -23,6 +23,9 @@ import { securitySolutionOnlyReadDelete, noCasesConnectors as noCasesConnectorRole, onlyActions as onlyActionsRole, + securitySolutionOnlyCreateComment, + securitySolutionOnlyNoCreateComment, + securitySolutionOnlyReadCreateComment, } from './roles'; import { User } from './types'; @@ -62,6 +65,24 @@ export const secOnlyNoDelete: User = { roles: [securitySolutionOnlyNoDelete.name], }; +export const secOnlyCreateComment: User = { + username: 'sec_only_create_comment', + password: 'sec_only_create_comment', + roles: [securitySolutionOnlyCreateComment.name], +}; + +export const secOnlyReadCreateComment: User = { + username: 'sec_only_read_create_comment', + password: 'sec_only_read_create_comment', + roles: [securitySolutionOnlyReadCreateComment.name], +}; + +export const secOnlyNoCreateComment: User = { + username: 'sec_only_no_create_comment', + password: 'sec_only_no_create_comment', + roles: [securitySolutionOnlyNoCreateComment.name], +}; + export const secOnlyRead: User = { username: 'sec_only_read', password: 'sec_only_read', @@ -159,6 +180,9 @@ export const users = [ secOnlyDelete, secOnlyReadDelete, secOnlyNoDelete, + secOnlyCreateComment, + secOnlyReadCreateComment, + secOnlyNoCreateComment, obsOnly, obsOnlyRead, obsOnlyReadAlerts, diff --git a/x-pack/test/cases_api_integration/common/plugins/security_solution/server/plugin.ts b/x-pack/test/cases_api_integration/common/plugins/security_solution/server/plugin.ts index e2c7cf4d88411..34f4c6d7423c0 100644 --- a/x-pack/test/cases_api_integration/common/plugins/security_solution/server/plugin.ts +++ b/x-pack/test/cases_api_integration/common/plugins/security_solution/server/plugin.ts @@ -115,6 +115,52 @@ export class FixturePlugin implements Plugin { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('createComment subprivilege', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + describe('user comments', () => { + it('should not create user comments', async () => { + // No privileges + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: secOnlyNoCreateComment, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: secOnlyNoCreateComment, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + + // Create + for (const scenario of [ + { user: secOnlyReadCreateComment, space: 'space1' }, + { user: secOnlyCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should create user comments`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: scenario, + expectedHttpCode: 200, + }); + }); + } + + // Update + it('should update comment without createComment privileges', async () => { + // Note: Not ideal behavior. A user unable to create should not be able to update, + // but it is a concession until the privileges are properly broken apart. + const commentUpdate = 'Heres an update because I do not want to make a new comment!'; + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + const patchedCase = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: superUser, space: 'space1' }, + }); + + const updatedCommentCase = await updateComment({ + supertest, + caseId: postedCase.id, + auth: { user: secOnlyNoCreateComment, space: 'space1' }, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: commentUpdate, + type: AttachmentType.user, + owner: 'securitySolutionFixture', + }, + }); + + const userActions = await getCaseUserActions({ + supertest, + caseID: postedCase.id, + auth: { user: superUser, space: 'space1' }, + }); + const commentUserAction = userActions[2]; + + expect(userActions.length).to.eql(3); + expect(commentUserAction.type).to.eql('comment'); + expect(commentUserAction.action).to.eql('update'); + expect(commentUserAction.comment_id).to.eql(updatedCommentCase.comments![0].id); + expect(commentUserAction.payload).to.eql({ + comment: { + comment: commentUpdate, + type: AttachmentType.user, + owner: 'securitySolutionFixture', + }, + }); + }); + + // Update + for (const scenario of [ + { user: secOnlyCreateComment, space: 'space1' }, + { user: secOnlyReadCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should not update user comments`, async () => { + const commentUpdate = 'Heres an update because I do not want to make a new comment!'; + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + const patchedCase = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentUserReq, + auth: { user: superUser, space: 'space1' }, + }); + + await updateComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: scenario, + req: { + id: patchedCase.comments![0].id, + version: patchedCase.comments![0].version, + comment: commentUpdate, + type: AttachmentType.user, + owner: 'securitySolutionFixture', + }, + expectedHttpCode: 403, + }); + }); + } + }); + + describe('alerts', () => { + it('should not attach alerts to the case', async () => { + // No privileges + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentAlertMultipleIdsReq, + auth: { user: secOnlyNoCreateComment, space: 'space1' }, + expectedHttpCode: 403, + }); + }); + + // Create + for (const scenario of [ + { user: secOnlyCreateComment, space: 'space1' }, + { user: secOnlyReadCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should attach alerts`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentAlertMultipleIdsReq, + auth: scenario, + expectedHttpCode: 200, + }); + }); + } + + // Delete + for (const scenario of [ + { user: secOnlyNoCreateComment, space: 'space1' }, + { user: secOnlyCreateComment, space: 'space1' }, + { user: secOnlyReadCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should not delete attached alerts`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: postCommentAlertMultipleIdsReq, + auth: { user: superUser, space: 'space1' }, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: scenario, + expectedHttpCode: 403, + }); + }); + } + }); + + describe('files', () => { + it('should not attach files to the case', async () => { + // No privileges + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: { user: secOnlyNoCreateComment, space: 'space1' }, + params: getFilesAttachmentReq(), + expectedHttpCode: 403, + }); + }); + + // Create + for (const scenario of [ + { user: secOnlyCreateComment, space: 'space1' }, + { user: secOnlyReadCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should attach files`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + const caseWithAttachments = await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: scenario, + params: getFilesAttachmentReq(), + expectedHttpCode: 200, + }); + + const fileAttachment = + caseWithAttachments.comments![0] as ExternalReferenceSOAttachmentPayload; + + expect(caseWithAttachments.totalComment).to.be(1); + expect(fileAttachment.externalReferenceMetadata).to.eql(fileAttachmentMetadata); + }); + } + + // Delete + for (const scenario of [ + { user: secOnlyCreateComment, space: 'space1' }, + { user: secOnlyReadCreateComment, space: 'space1' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should not delete attached files`, async () => { + const postedCase = await createCase( + supertestWithoutAuth, + getPostCaseRequest({ owner: 'securitySolutionFixture' }), + 200, + { + user: superUser, + space: 'space1', + } + ); + + await createComment({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: { user: superUser, space: 'space1' }, + params: getFilesAttachmentReq(), + expectedHttpCode: 200, + }); + + await deleteAllComments({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + auth: scenario, + expectedHttpCode: 403, + }); + }); + } + }); + }); +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/delete_sub_privilege.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/delete_sub_privilege.ts index 75388fe0bfe19..22ac95050cffa 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/delete_sub_privilege.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/delete_sub_privilege.ts @@ -24,6 +24,7 @@ import { } from '../../../common/lib/api'; import { superUser, + secOnlyCreateComment, secOnlyDelete, secOnlyNoDelete, } from '../../../common/lib/authentication/users'; @@ -306,7 +307,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: caseInfo.id, params: postCommentUserReq, - auth: { user: secOnlyNoDelete, space: 'space1' }, + auth: { user: secOnlyCreateComment, space: 'space1' }, }); await deleteComment({ diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts index c1038eb964313..3112dfab7ec66 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts @@ -36,6 +36,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => { loadTestFile(require.resolve('./attachments_framework/registered_persistable_state_trial')); // sub privileges are only available with a license above basic loadTestFile(require.resolve('./delete_sub_privilege')); + loadTestFile(require.resolve('./create_comment_sub_privilege.ts')); loadTestFile(require.resolve('./user_profiles/get_current')); // Internal routes diff --git a/x-pack/test/functional/services/ml/security_common.ts b/x-pack/test/functional/services/ml/security_common.ts index 6d9aee298beaa..05738e664796d 100644 --- a/x-pack/test/functional/services/ml/security_common.ts +++ b/x-pack/test/functional/services/ml/security_common.ts @@ -150,7 +150,7 @@ export function MachineLearningSecurityCommonProvider({ getService }: FtrProvide savedObjectsManagement: ['all'], advancedSettings: ['all'], indexPatterns: ['all'], - generalCases: ['all'], + generalCasesV2: ['all'], ml: ['none'], }, spaces: ['*'], @@ -179,7 +179,7 @@ export function MachineLearningSecurityCommonProvider({ getService }: FtrProvide savedObjectsManagement: ['all'], advancedSettings: ['all'], indexPatterns: ['all'], - generalCases: ['all'], + generalCasesV2: ['all'], }, spaces: ['*'], }, diff --git a/x-pack/test/functional/services/observability/users.ts b/x-pack/test/functional/services/observability/users.ts index 0e2915190d126..2386c08a4f90e 100644 --- a/x-pack/test/functional/services/observability/users.ts +++ b/x-pack/test/functional/services/observability/users.ts @@ -58,7 +58,7 @@ export function ObservabilityUsersProvider({ getPageObject, getService }: FtrPro */ const defineBasicObservabilityRole = ( features: Partial<{ - observabilityCases: string[]; + observabilityCasesV2: string[]; apm: string[]; logs: string[]; infrastructure: string[]; diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/common/roles.ts b/x-pack/test/functional_with_es_ssl/apps/cases/common/roles.ts index f06c8745d6df6..0e8cb455ad299 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/common/roles.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/common/roles.ts @@ -25,7 +25,7 @@ export const casesReadDelete: Role = { kibana: [ { feature: { - generalCases: ['minimal_read', 'cases_delete'], + generalCasesV2: ['minimal_read', 'cases_delete'], actions: ['all'], actionsSimulators: ['all'], }, @@ -49,7 +49,7 @@ export const casesNoDelete: Role = { kibana: [ { feature: { - generalCases: ['minimal_all'], + generalCasesV2: ['minimal_all'], actions: ['all'], actionsSimulators: ['all'], }, @@ -73,7 +73,7 @@ export const casesAll: Role = { kibana: [ { feature: { - generalCases: ['all'], + generalCasesV2: ['all'], actions: ['all'], actionsSimulators: ['all'], }, diff --git a/x-pack/test/functional_with_es_ssl/plugins/cases/public/application.tsx b/x-pack/test/functional_with_es_ssl/plugins/cases/public/application.tsx index 31c0b25f51e94..6ab6a1cce3610 100644 --- a/x-pack/test/functional_with_es_ssl/plugins/cases/public/application.tsx +++ b/x-pack/test/functional_with_es_ssl/plugins/cases/public/application.tsx @@ -42,6 +42,8 @@ const permissions = { push: true, connectors: true, settings: true, + createComment: true, + reopenCase: true, }; const attachments = [{ type: AttachmentType.user as const, comment: 'test' }]; diff --git a/x-pack/test/observability_functional/apps/observability/feature_controls/observability_security.ts b/x-pack/test/observability_functional/apps/observability/feature_controls/observability_security.ts index a71c83a5221c3..81fb1d23ba33e 100644 --- a/x-pack/test/observability_functional/apps/observability/feature_controls/observability_security.ts +++ b/x-pack/test/observability_functional/apps/observability/feature_controls/observability_security.ts @@ -43,7 +43,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); await observability.users.setTestUserRole( observability.users.defineBasicObservabilityRole({ - observabilityCases: ['all'], + observabilityCasesV2: ['all'], logs: ['all'], }) ); @@ -96,7 +96,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); await observability.users.setTestUserRole( observability.users.defineBasicObservabilityRole({ - observabilityCases: ['read'], + observabilityCasesV2: ['read'], logs: ['all'], }) ); diff --git a/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts b/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts index 33b2ad3ba329a..ccb4264147523 100644 --- a/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts +++ b/x-pack/test/observability_functional/apps/observability/pages/alerts/add_to_case.ts @@ -29,7 +29,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { before(async () => { await observability.users.setTestUserRole( observability.users.defineBasicObservabilityRole({ - observabilityCases: ['all'], + observabilityCasesV2: ['all'], logs: ['all'], }) ); @@ -75,7 +75,7 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => { before(async () => { await observability.users.setTestUserRole( observability.users.defineBasicObservabilityRole({ - observabilityCases: ['read'], + observabilityCasesV2: ['read'], logs: ['all'], }) ); diff --git a/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts b/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts index ac6343f8e7170..90fc09af9c6ad 100644 --- a/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts +++ b/x-pack/test/observability_functional/apps/observability/pages/cases/case_details.ts @@ -33,7 +33,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { before(async () => { await observability.users.setTestUserRole( observability.users.defineBasicObservabilityRole({ - observabilityCases: ['all'], + observabilityCasesV2: ['all'], logs: ['all'], }) ); diff --git a/x-pack/test/security_api_integration/tests/features/deprecated_features.ts b/x-pack/test/security_api_integration/tests/features/deprecated_features.ts index 6e868fc5946ec..29135ff2440b2 100644 --- a/x-pack/test/security_api_integration/tests/features/deprecated_features.ts +++ b/x-pack/test/security_api_integration/tests/features/deprecated_features.ts @@ -181,6 +181,9 @@ export default function ({ getService }: FtrProviderContext) { "case_3_feature_a", "case_4_feature_a", "case_4_feature_b", + "generalCases", + "observabilityCases", + "securitySolutionCases", ] `); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts index 826ca78228b61..9a60b4b281b07 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/investigations/timelines/export.cy.ts @@ -45,8 +45,9 @@ describe('Export timelines', { tags: ['@ess', '@serverless'] }, () => { /** * TODO: Good candidate for converting to a jest Test * https://github.com/elastic/kibana/issues/195612 + * Failing: https://github.com/elastic/kibana/issues/187550 */ - it('should export custom timeline(s)', function () { + it.skip('should export custom timeline(s)', function () { cy.log('Export a custom timeline via timeline actions'); exportTimeline(this.timelineId1); diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts b/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts index 7f2d0dea8b545..bbbaaa1e240a6 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/privileges.ts @@ -66,6 +66,7 @@ export const secAll: Role = { securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], securitySolutionCases: ['all'], + securitySolutionCasesV2: ['all'], actions: ['all'], actionsSimulators: ['all'], }, @@ -99,6 +100,7 @@ export const secReadCasesAll: Role = { securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], securitySolutionCases: ['all'], + securitySolutionCasesV2: ['all'], actions: ['all'], actionsSimulators: ['all'], }, @@ -132,6 +134,7 @@ export const secAllCasesOnlyReadDelete: Role = { securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], securitySolutionCases: ['cases_read', 'cases_delete'], + securitySolutionCasesV2: ['cases_read', 'cases_delete'], actions: ['all'], actionsSimulators: ['all'], }, @@ -165,6 +168,7 @@ export const secAllCasesNoDelete: Role = { securitySolutionAssistant: ['all'], securitySolutionAttackDiscovery: ['all'], securitySolutionCases: ['minimal_all'], + securitySolutionCasesV2: ['minimal_all'], actions: ['all'], actionsSimulators: ['all'], }, diff --git a/x-pack/test/spaces_api_integration/common/suites/create.ts b/x-pack/test/spaces_api_integration/common/suites/create.ts index 795d177805f89..d84945fbfe032 100644 --- a/x-pack/test/spaces_api_integration/common/suites/create.ts +++ b/x-pack/test/spaces_api_integration/common/suites/create.ts @@ -81,9 +81,11 @@ export function createTestSuiteFactory(esArchiver: any, supertest: SuperTest) 'inventory', 'logs', 'observabilityCases', + 'observabilityCasesV2', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', 'securitySolutionCases', + 'securitySolutionCasesV2', 'siem', 'slo', 'uptime', diff --git a/x-pack/test/spaces_api_integration/common/suites/get_all.ts b/x-pack/test/spaces_api_integration/common/suites/get_all.ts index b90128ab12c70..9d51cbb12e469 100644 --- a/x-pack/test/spaces_api_integration/common/suites/get_all.ts +++ b/x-pack/test/spaces_api_integration/common/suites/get_all.ts @@ -80,9 +80,11 @@ const ALL_SPACE_RESULTS: Space[] = [ 'inventory', 'logs', 'observabilityCases', + 'observabilityCasesV2', 'securitySolutionAssistant', 'securitySolutionAttackDiscovery', 'securitySolutionCases', + 'securitySolutionCasesV2', 'siem', 'slo', 'uptime', diff --git a/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts b/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts index e691f84d7bdc7..4a43c3831627c 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts @@ -66,6 +66,7 @@ export default function ({ getService }: FtrProviderContext) { maintenanceWindow: 0, stackAlerts: 0, generalCases: 0, + generalCasesV2: 0, maps: 2, canvas: 2, ml: 0, @@ -73,6 +74,7 @@ export default function ({ getService }: FtrProviderContext) { fleet: 0, osquery: 0, observabilityCases: 0, + observabilityCasesV2: 0, uptime: 0, slo: 0, infrastructure: 0, @@ -84,6 +86,7 @@ export default function ({ getService }: FtrProviderContext) { searchInferenceEndpoints: 0, siem: 0, securitySolutionCases: 0, + securitySolutionCasesV2: 0, securitySolutionAssistant: 0, securitySolutionAttackDiscovery: 0, discover: 0, diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 2ba14ceb1218c..9db41aecbb612 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -187,6 +187,6 @@ "@kbn/alerting-types", "@kbn/ai-assistant-common", "@kbn/core-deprecations-common", - "@kbn/usage-collection-plugin" + "@kbn/usage-collection-plugin", ] } diff --git a/x-pack/test_serverless/shared/lib/security/default_http_headers.ts b/x-pack/test_serverless/shared/lib/security/default_http_headers.ts index 03c96905d6b06..18293b74ce116 100644 --- a/x-pack/test_serverless/shared/lib/security/default_http_headers.ts +++ b/x-pack/test_serverless/shared/lib/security/default_http_headers.ts @@ -8,4 +8,5 @@ export const STANDARD_HTTP_HEADERS = Object.freeze({ 'kbn-xsrf': 'cypress-creds-via-env', 'x-elastic-internal-origin': 'security-solution', + 'elastic-api-version': '2023-10-31', }); diff --git a/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml b/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml index 22b3fd31c423b..61d3378de4c68 100644 --- a/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml +++ b/x-pack/test_serverless/shared/lib/security/kibana_roles/project_controller_security_roles.yml @@ -493,6 +493,7 @@ soc_manager: - application: "kibana-.kibana" privileges: - feature_ml.read + - feature_generalCases.all - feature_siem.all - feature_siem.read_alerts - feature_siem.crud_alerts @@ -509,6 +510,7 @@ soc_manager: - feature_siem.execute_operations_all - feature_siem.scan_operations_all - feature_securitySolutionCases.all + - feature_observabilityCases.all - feature_securitySolutionAssistant.all - feature_securitySolutionAttackDiscovery.all - feature_actions.all