[8.x] [Cases] [Security Solution] New cases subfeatures, add comments and reopen cases (#194898) (#200807)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Cases] [Security Solution] New cases subfeatures, add comments and
reopen cases (#194898)](https://github.com/elastic/kibana/pull/194898)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Kevin
Qualters","email":"56408403+kqualters-elastic@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-11-19T19:15:38Z","message":"[Cases]
[Security Solution] New cases subfeatures, add comments and reopen cases
(#194898)\n\n## Summary\r\n\r\nThis pr adds 2 new sub feature
permissions to the cases plugin in\r\nstack/security/observability, that
behave as follows. The first is for\r\ncontrolling the ability to reopen
cases. When Cases has the read\r\npermission, and the reopen permission
is not enabled, users have\r\npermissions as before. When enabled, users
can move cases from closed to\r\nopen/in progress, but nothing else. If
a user has all and this\r\npermission, they can do anything as before,
if the option is unselected,\r\nthey can change case properties, and
change a case from open to\r\nanything, in progress to anything, but if
the case is closed, are unable\r\nto reopen it.\r\n\r\nThe 2nd
permission is 'Add comment'. When enabled and the user has case\r\nread
permissions, users can add comments, but not make any other
changes\r\nto the case. When the user has read and this deselected, read
functions\r\nas before. When a user has this permission and cases is
all, this\r\nfunctions as all. When they have all but this permission is
deselected,\r\nthe user can do everything normally, except add cases
comments.\r\n\r\n### Checklist\r\n\r\n- [x] Any text added follows
[EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [x] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Michael Olorunnisola
<michael.olorunnisola@elastic.co>\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"0afae423443ba13c47a263c4cbc270ea09942148","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["v9.0.0","release_note:feature","ci:project-deploy-observability","Team:obs-ux-management","apm:review","backport:version","v8.17.0"],"title":"[Cases]
[Security Solution] New cases subfeatures, add comments and reopen
cases","number":194898,"url":"https://github.com/elastic/kibana/pull/194898","mergeCommit":{"message":"[Cases]
[Security Solution] New cases subfeatures, add comments and reopen cases
(#194898)\n\n## Summary\r\n\r\nThis pr adds 2 new sub feature
permissions to the cases plugin in\r\nstack/security/observability, that
behave as follows. The first is for\r\ncontrolling the ability to reopen
cases. When Cases has the read\r\npermission, and the reopen permission
is not enabled, users have\r\npermissions as before. When enabled, users
can move cases from closed to\r\nopen/in progress, but nothing else. If
a user has all and this\r\npermission, they can do anything as before,
if the option is unselected,\r\nthey can change case properties, and
change a case from open to\r\nanything, in progress to anything, but if
the case is closed, are unable\r\nto reopen it.\r\n\r\nThe 2nd
permission is 'Add comment'. When enabled and the user has case\r\nread
permissions, users can add comments, but not make any other
changes\r\nto the case. When the user has read and this deselected, read
functions\r\nas before. When a user has this permission and cases is
all, this\r\nfunctions as all. When they have all but this permission is
deselected,\r\nthe user can do everything normally, except add cases
comments.\r\n\r\n### Checklist\r\n\r\n- [x] Any text added follows
[EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [x] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Michael Olorunnisola
<michael.olorunnisola@elastic.co>\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"0afae423443ba13c47a263c4cbc270ea09942148"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/194898","number":194898,"mergeCommit":{"message":"[Cases]
[Security Solution] New cases subfeatures, add comments and reopen cases
(#194898)\n\n## Summary\r\n\r\nThis pr adds 2 new sub feature
permissions to the cases plugin in\r\nstack/security/observability, that
behave as follows. The first is for\r\ncontrolling the ability to reopen
cases. When Cases has the read\r\npermission, and the reopen permission
is not enabled, users have\r\npermissions as before. When enabled, users
can move cases from closed to\r\nopen/in progress, but nothing else. If
a user has all and this\r\npermission, they can do anything as before,
if the option is unselected,\r\nthey can change case properties, and
change a case from open to\r\nanything, in progress to anything, but if
the case is closed, are unable\r\nto reopen it.\r\n\r\nThe 2nd
permission is 'Add comment'. When enabled and the user has case\r\nread
permissions, users can add comments, but not make any other
changes\r\nto the case. When the user has read and this deselected, read
functions\r\nas before. When a user has this permission and cases is
all, this\r\nfunctions as all. When they have all but this permission is
deselected,\r\nthe user can do everything normally, except add cases
comments.\r\n\r\n### Checklist\r\n\r\n- [x] Any text added follows
[EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials\r\n- [x] [Unit
or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios\r\n\r\n---------\r\n\r\nCo-authored-by: Michael Olorunnisola
<michael.olorunnisola@elastic.co>\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"0afae423443ba13c47a263c4cbc270ea09942148"}},{"branch":"8.x","label":"v8.17.0","branchLabelMappingKey":"^v8.17.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2024-11-20 08:13:06 +11:00 committed by GitHub
parent 4a9f70d814
commit c501d2f589
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
145 changed files with 3541 additions and 516 deletions

View file

@ -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

View file

@ -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"]
},

View file

@ -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';

View file

@ -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<CasesSubFeatureId> => ({
@ -17,3 +28,11 @@ export const getCasesFeature = (
baseKibanaSubFeatureIds: getCasesBaseKibanaSubFeatureIds(),
subFeaturesMap: getCasesSubFeaturesMap(params),
});
export const getCasesV2Feature = (
params: CasesFeatureParams
): ProductFeatureParams<CasesSubFeatureId> => ({
baseKibanaFeature: getCasesBaseKibanaFeatureV2(params),
baseKibanaSubFeatureIds: getCasesBaseKibanaSubFeatureIdsV2(),
subFeaturesMap: getCasesSubFeaturesMapV2(params),
});

View file

@ -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';

View file

@ -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'] }],
},
},
},
};
};

View file

@ -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'] }],
},
],
},

View file

@ -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<CasesSubFeatureId>
>;

View file

@ -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],

View file

@ -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, SubFeatureConfig>([
[CasesSubFeatureId.deleteCases, deleteCasesSubFeature],
[CasesSubFeatureId.casesSettings, casesSettingsCasesSubFeature],
/* The below sub features were newly added in v2 (8.17) */
[CasesSubFeatureId.createComment, casesAddCommentsCasesSubFeature],
[CasesSubFeatureId.reopenCase, casesreopenCaseSubFeature],
]);
};

View file

@ -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;

View file

@ -148,6 +148,8 @@ export enum SecuritySubFeatureId {
export enum CasesSubFeatureId {
deleteCases = 'deleteCasesSubFeature',
casesSettings = 'casesSettingsSubFeature',
createComment = 'createCommentSubFeature',
reopenCase = 'reopenCaseSubFeature',
}
/** Sub-features IDs for Security Assistant */

View file

@ -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",

View file

@ -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",

View file

@ -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),
]);
}
}

View file

@ -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;

View file

@ -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

View file

@ -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';

View file

@ -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;
}

View file

@ -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",
],

View file

@ -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,
};
};

View file

@ -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",
],

View file

@ -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,
});

View file

@ -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', () => {

View file

@ -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`;
};

View file

@ -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,
}

View file

@ -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,
};
};

View file

@ -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 },
});

View file

@ -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 },
});

View file

@ -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,
]
);
};

View file

@ -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 },

View file

@ -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<Omit<CasesPermissions, 'all'>> = {}) => {
const create = overrides.create ?? true;
@ -47,7 +85,18 @@ export const buildCasesPermissions = (overrides: Partial<Omit<CasesPermissions,
const push = overrides.push ?? true;
const connectors = overrides.connectors ?? true;
const settings = overrides.settings ?? true;
const all = create && read && update && deletePermissions && push && settings && connectors;
const reopenCase = overrides.reopenCase ?? true;
const createComment = overrides.createComment ?? true;
const all =
create &&
read &&
update &&
deletePermissions &&
push &&
settings &&
connectors &&
reopenCase &&
createComment;
return {
all,
@ -58,6 +107,8 @@ export const buildCasesPermissions = (overrides: Partial<Omit<CasesPermissions,
push,
connectors,
settings,
reopenCase,
createComment,
};
};
@ -71,6 +122,8 @@ export const noCasesCapabilities = () =>
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<CasesCapabilities>) =
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,
};
};

View file

@ -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);
});
});

View file

@ -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<Pick<CasesUI[number], 'status'>>) => {
// 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;
};

View file

@ -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);
});
});

View file

@ -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<typeof useStatusAction>;

View file

@ -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(
<TestProviders permissions={noCreateCasesPermissions()}>
<TestProviders permissions={noCreateCommentCasesPermissions()}>
<AddComment {...{ ...addCommentProps }} />
</TestProviders>
);
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(
<TestProviders permissions={onlyCreateCommentPermissions()}>
<AddComment {...{ ...addCommentProps }} />
</TestProviders>
);
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
expect(screen.queryByTestId('add-comment-form-wrapper')).toBeInTheDocument();
});
it('should post comment on submit click', async () => {

View file

@ -191,8 +191,8 @@ export const AddComment = React.memo(
size="xl"
/>
)}
{permissions.create && (
<Form form={form}>
{permissions.createComment && (
<Form form={form} data-test-subj="add-comment-form-wrapper">
<UseField
path={fieldName}
component={MarkdownEditorForm}

View file

@ -17,6 +17,7 @@ import { useActions } from './use_actions';
import { basicCase } from '../../containers/mock';
import * as api from '../../containers/api';
import type { AppMockRenderer } from '../../common/mock';
import { CaseStatuses } from '../../../common/types/domain';
import {
createAppMockRenderer,
noDeleteCasesPermissions,
@ -382,5 +383,98 @@ describe('useActions', () => {
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);
});
});
});

View file

@ -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: (
<FormattedMessage
@ -97,7 +104,8 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean
key: `case-action-status-panel-${theCase.id}`,
'data-test-subj': `case-action-status-panel-${theCase.id}`,
});
}
if (severityAction.canUpdateSeverity) {
mainPanelItems.push({
name: (
<FormattedMessage
@ -137,13 +145,14 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean
mainPanelItems.push(deleteAction.getAction([theCase]));
}
if (canUpdate) {
if (statusAction.canUpdateStatus || !shouldDisableStatus) {
panelsToBuild.push({
id: 1,
title: i18n.STATUS,
items: statusAction.getActions([theCase]),
});
}
if (severityAction.canUpdateSeverity) {
panelsToBuild.push({
id: 2,
title: i18n.SEVERITY,
@ -162,6 +171,7 @@ const ActionColumnComponent: React.FC<{ theCase: CaseUI; disableActions: boolean
statusAction,
tagsAction,
theCase,
shouldDisableStatus,
]);
return (
@ -232,7 +242,7 @@ interface UseBulkActionsProps {
export const useActions = ({ disableActions }: UseBulkActionsProps): UseBulkActionsReturnValue => {
const { permissions } = useCasesContext();
const shouldShowActions = permissions.update || permissions.delete;
const shouldShowActions = permissions.update || permissions.delete || permissions.reopenCase;
return {
actions: shouldShowActions

View file

@ -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(
<>
<EuiContextMenu initialPanelId={0} panels={panels} />
{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(
<>
<EuiContextMenu initialPanelId={0} panels={panels} />
{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();
});
});
});
});

View file

@ -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,

View file

@ -94,7 +94,9 @@ export const CasesTableUtilityBar: FunctionComponent<Props> = 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;

View file

@ -39,7 +39,7 @@ const CasesAppComponent: React.FC<CasesAppProps> = ({
getFilesClient,
owner: [APP_OWNER],
useFetchAlertData: () => [false, {}],
permissions: userCapabilities.generalCases,
permissions: userCapabilities.generalCasesV2,
basePath: '/',
features: { alerts: { enabled: true, sync: false } },
})}

View file

@ -21,15 +21,15 @@ jest.mock('../../common/lib/kibana');
const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
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 = {

View file

@ -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', '');
};

View file

@ -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<CaseActionBarProps> = ({
[caseData.settings, onUpdateField]
);
const shouldDisableStatusFn = useShouldDisableStatus();
const isStatusMenuDisabled = useMemo(() => {
return shouldDisableStatusFn([caseData]);
}, [caseData, shouldDisableStatusFn]);
return (
<EuiFlexGroup gutterSize="l" justifyContent="flexEnd" data-test-subj="case-action-bar-wrapper">
<EuiFlexItem
@ -83,7 +89,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
<ActionBarStatusItem title={i18n.STATUS} data-test-subj="case-view-status">
<StatusContextMenu
currentStatus={caseData.status}
disabled={!permissions.update}
disabled={isStatusMenuDisabled}
isLoading={isLoading}
onStatusChanged={onStatusChanged}
/>

View file

@ -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(
<StatusContextMenu currentStatus={CaseStatuses.open} onStatusChanged={onStatusChanged} />
<TestProviders>
<StatusContextMenu currentStatus={CaseStatuses.open} onStatusChanged={onStatusChanged} />
</TestProviders>
);
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(
<StatusContextMenu
disabled={true}
currentStatus={CaseStatuses.open}
onStatusChanged={onStatusChanged}
/>
<TestProviders>
<StatusContextMenu
disabled={true}
currentStatus={CaseStatuses.open}
onStatusChanged={onStatusChanged}
/>
</TestProviders>
);
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(
<StatusContextMenu currentStatus={CaseStatuses.closed} onStatusChanged={onStatusChanged} />
<TestProviders>
<StatusContextMenu currentStatus={CaseStatuses.closed} onStatusChanged={onStatusChanged} />
</TestProviders>
);
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(
<StatusContextMenu currentStatus={CaseStatuses.open} onStatusChanged={onStatusChanged} />
<TestProviders>
<StatusContextMenu currentStatus={CaseStatuses.open} onStatusChanged={onStatusChanged} />
</TestProviders>
);
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(
<StatusContextMenu currentStatus={CaseStatuses.open} onStatusChanged={onStatusChanged} />
<TestProviders>
<StatusContextMenu currentStatus={CaseStatuses.open} onStatusChanged={onStatusChanged} />
</TestProviders>
);
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(
<TestProviders>
<StatusContextMenu currentStatus={CaseStatuses.open} onStatusChanged={onStatusChanged} />
</TestProviders>
);
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(
<TestProviders>
<StatusContextMenu currentStatus={CaseStatuses.open} onStatusChanged={onStatusChanged} />
</TestProviders>
);
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(
<TestProviders>
<StatusContextMenu currentStatus={CaseStatuses.open} onStatusChanged={onStatusChanged} />
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="case-view-status-dropdown"] button`).exists()
).toBeFalsy();
});
});

View file

@ -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<Props> = ({
onStatusChanged,
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const shouldDisableStatus = useShouldDisableStatus();
const togglePopover = useCallback(
() => setIsPopoverOpen((prevPopoverStatus) => !prevPopoverStatus),
[]
@ -57,17 +59,19 @@ const StatusContextMenuComponent: React.FC<Props> = ({
const panelItems = useMemo(
() =>
caseStatuses.map((status: CaseStatuses) => (
<EuiContextMenuItem
data-test-subj={`case-view-status-dropdown-${status}`}
icon={status === currentStatus ? 'check' : 'empty'}
key={status}
onClick={() => onContextMenuItemClick(status)}
>
<Status status={status} />
</EuiContextMenuItem>
)),
[currentStatus, onContextMenuItemClick]
caseStatuses
.filter((_: CaseStatuses) => !shouldDisableStatus([{ status: currentStatus }]))
.map((status: CaseStatuses) => (
<EuiContextMenuItem
data-test-subj={`case-view-status-dropdown-${status}`}
icon={status === currentStatus ? 'check' : 'empty'}
key={status}
onClick={() => onContextMenuItemClick(status)}
>
<Status status={status} />
</EuiContextMenuItem>
)),
[currentStatus, onContextMenuItemClick, shouldDisableStatus]
);
if (disabled) {

View file

@ -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,
]
);

View file

@ -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(<AddFile caseId={'foobar'} />);
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(<AddFile caseId={'foobar'} />);

View file

@ -107,7 +107,7 @@ const AddFileComponent: React.FC<AddFileProps> = ({ caseId }) => {
[caseId, createAttachments, owner, refreshAttachmentsTable, showDangerToast, showSuccessToast]
);
return permissions.create && permissions.update ? (
return permissions.createComment ? (
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="cases-files-add"

View file

@ -262,13 +262,13 @@ describe('RecentCases', () => {
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(<RecentCases {...{ ...defaultProps, maxCasesToShow: 2 }} />);

View file

@ -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: (

View file

@ -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);
});
});

View file

@ -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 };
};

View file

@ -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,

View file

@ -50,6 +50,8 @@ const helpersMock: jest.Mocked<CasesPublicStart['helpers']> = {
push: false,
connectors: false,
settings: false,
createComment: false,
reopenCase: false,
}),
getRuleIdFromEvent: jest.fn(),
groupAlertsByRule: jest.fn(),

View file

@ -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 {

View file

@ -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\\"",
},
],
]
`;

View file

@ -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}`;
}
/**

View file

@ -1459,4 +1459,80 @@ describe('authorization', () => {
});
});
});
describe('ensureAuthorized with operation arrays', () => {
let auth: Authorization;
let securityStart: ReturnType<typeof securityMock.createStart>;
let featuresStart: jest.Mocked<FeaturesPluginStart>;
let spacesStart: jest.Mocked<SpacesPluginStart>;
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();
});
});
});

View file

@ -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,

View file

@ -59,7 +59,7 @@ const EVENT_TYPES: Record<string, ArrayElement<EcsEvent['type']>> = {
};
/**
* 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 = {

View file

@ -63,6 +63,7 @@ export enum WriteOperations {
UpdateComment = 'updateComment',
CreateConfiguration = 'createConfiguration',
UpdateConfiguration = 'updateConfiguration',
ReopenCase = 'reopenCase',
}
/**
@ -75,7 +76,7 @@ export interface OperationDetails {
ecsType: ArrayElement<EcsEvent['type']>;
/**
* 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.

View file

@ -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"`
);
});
});
});
});

View file

@ -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<string, OwnerEntity> = new Map<string, OwnerEntity>();
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<string, CaseSavedObjectTransformed>());
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) {

View file

@ -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',
]);
});
});

View file

@ -507,6 +507,7 @@ describe('utils', () => {
'cases:my-owner/updateComment',
'cases:my-owner/deleteComment',
'cases:my-owner/findConfigurations',
'cases:my-owner/reopenCase',
]);
});
});

View file

@ -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`,
];
};

View file

@ -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';

View file

@ -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() });

View file

@ -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] },
],
},
],
},

View file

@ -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,
},
],
},
],
},
],
};
};

View file

@ -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({

View file

@ -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[];
};
/**

View file

@ -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 [],
},

View file

@ -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'],
},

View file

@ -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;

View file

@ -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),
})
);

View file

@ -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 {

View file

@ -120,6 +120,8 @@ describe('AddToCaseAction', function () {
push: false,
connectors: false,
settings: false,
createComment: false,
reopenCase: false,
},
})
);

View file

@ -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

View file

@ -159,7 +159,7 @@ export function AlertActions({
);
const actionsMenuItems = [
...(userCasesPermissions.create && userCasesPermissions.read
...(userCasesPermissions.createComment && userCasesPermissions.read
? [
<EuiContextMenuItem
data-test-subj="add-to-existing-case-action"

View file

@ -28,6 +28,8 @@ const defaultProps: CasesProps = {
update: true,
connectors: true,
settings: true,
reopenCase: true,
createComment: true,
},
};
@ -45,5 +47,7 @@ CasesPageWithNoPermissions.args = {
update: false,
connectors: false,
settings: false,
reopenCase: false,
createComment: false,
},
};

View file

@ -0,0 +1,151 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license 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 { casesFeatureId, casesFeatureIdV2, observabilityFeatureId } from '../../common';
export const getCasesFeature = (
casesCapabilities: CasesUiCapabilities,
casesApiTags: CasesApiTags
): KibanaFeatureConfig => ({
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'] }],
},
],
},
],
},
],
});

View file

@ -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,
},
],
},
],
},
],
});

View file

@ -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<ObservabilityPlugin['setup']>;
@ -110,112 +111,8 @@ export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
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<AnnotationsAPI> | undefined;

View file

@ -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

View file

@ -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,
});

View file

@ -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'],
},
],

View file

@ -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;

View file

@ -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": ["*"],

View file

@ -33,8 +33,8 @@ const TakeActionComponent: React.FC<Props> = ({ 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,

View file

@ -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,
});

View file

@ -59,7 +59,7 @@ export const useAddToExistingCase = ({
disabled:
lensAttributes == null ||
timeRange == null ||
!userCasesPermissions.create ||
!userCasesPermissions.createComment ||
!userCasesPermissions.read,
};
};

View file

@ -60,7 +60,7 @@ export const useAddToNewCase = ({
disabled:
lensAttributes == null ||
timeRange == null ||
!userCasesPermissions.create ||
!userCasesPermissions.createComment ||
!userCasesPermissions.read,
};
};

View file

@ -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],

View file

@ -88,6 +88,8 @@ jest.mock('../../../../common/lib/kibana', () => {
update: true,
delete: true,
push: true,
createComment: true,
reopenCase: true,
}),
getRuleIdFromEvent: jest.fn(),
},

View file

@ -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 {

Some files were not shown because too many files have changed in this diff Show more