From b151c8192501b9fc66def17b6f7eb38cb88661ec Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 1 Mar 2021 22:46:22 -0500 Subject: [PATCH] [SECURITY SOLUTIONS] Bug case connector (#93104) * bring back case connector to design * disable connector sir in collection * missing to only create collection type * fix fields connector when you need to hide service-now sir --- .../cases/components/case_view/index.tsx | 3 + .../connectors_dropdown.test.tsx | 153 ++++++++++++++++-- .../configure_cases/connectors_dropdown.tsx | 49 +++--- .../components/connector_selector/form.tsx | 3 + .../connectors/case/alert_fields.tsx | 21 +-- .../connectors/case/existing_case.tsx | 114 +++++-------- .../connectors/case/translations.ts | 16 +- .../cases/components/create/connector.tsx | 24 ++- .../public/cases/components/create/form.tsx | 134 +++++++-------- .../cases/components/create/form_context.tsx | 31 ++-- .../cases/components/edit_connector/index.tsx | 5 +- .../create_case_modal.tsx | 13 +- .../use_create_case_modal/index.tsx | 5 +- .../public/cases/containers/api.ts | 3 + .../public/cases/containers/types.ts | 1 + .../public/cases/containers/use_get_cases.tsx | 8 +- .../rules/rule_actions_field/index.test.tsx | 68 +++++++- .../rules/rule_actions_field/index.tsx | 61 ++++++- .../rules/step_rule_actions/index.tsx | 8 +- .../use_manage_case_action.tsx | 63 ++++++++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 22 files changed, 567 insertions(+), 218 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 108e020d014c4c..83a0c4e7acd3d6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -447,6 +447,9 @@ export const CaseComponent = React.memo( caseFields={caseData.connector.fields} connectors={connectors} disabled={!userCanCrud} + hideConnectorServiceNowSir={ + subCaseId != null || caseData.type === CaseType.collection + } isLoading={isLoadingConnectors || (isLoading && updateKey === 'connector')} onSubmit={onSubmitConnector} selectedConnector={caseData.connector.id} diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx index e8c074faed32ef..1f1876756773d6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.test.tsx @@ -34,22 +34,122 @@ describe('ConnectorsDropdown', () => { test('it formats the connectors correctly', () => { const selectProps = wrapper.find(EuiSuperSelect).props(); - expect(selectProps.options).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - value: 'none', - 'data-test-subj': 'dropdown-connector-no-connector', - }), - expect.objectContaining({ - value: 'servicenow-1', - 'data-test-subj': 'dropdown-connector-servicenow-1', - }), - expect.objectContaining({ - value: 'resilient-2', - 'data-test-subj': 'dropdown-connector-resilient-2', - }), - ]) - ); + expect(selectProps.options).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "dropdown-connector-no-connector", + "inputDisplay": + + + + + + No connector selected + + + , + "value": "none", + }, + Object { + "data-test-subj": "dropdown-connector-servicenow-1", + "inputDisplay": + + + + + + My Connector + + + , + "value": "servicenow-1", + }, + Object { + "data-test-subj": "dropdown-connector-resilient-2", + "inputDisplay": + + + + + + My Connector 2 + + + , + "value": "resilient-2", + }, + Object { + "data-test-subj": "dropdown-connector-jira-1", + "inputDisplay": + + + + + + Jira + + + , + "value": "jira-1", + }, + Object { + "data-test-subj": "dropdown-connector-servicenow-sir", + "inputDisplay": + + + + + + My Connector SIR + + + , + "value": "servicenow-sir", + }, + ] + `); }); test('it disables the dropdown', () => { @@ -79,4 +179,25 @@ describe('ConnectorsDropdown', () => { expect(newWrapper.find('button span:not([data-euiicon-type])').text()).toEqual('My Connector'); }); + + test('if the props hideConnectorServiceNowSir is true, the connector should not be part of the list of options ', () => { + const newWrapper = mount( + , + { + wrappingComponent: TestProviders, + } + ); + const selectProps = newWrapper.find(EuiSuperSelect).props(); + const options = selectProps.options as Array<{ 'data-test-subj': string }>; + expect( + options.some((o) => o['data-test-subj'] === 'dropdown-connector-servicenow-1') + ).toBeTruthy(); + expect( + options.some((o) => o['data-test-subj'] === 'dropdown-connector-servicenow-sir') + ).toBeFalsy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx index ab4b9fcfe70930..b8eacb9dfdd91d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors_dropdown.tsx @@ -9,6 +9,7 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; import styled from 'styled-components'; +import { ConnectorTypes } from '../../../../../case/common/api'; import { ActionConnector } from '../../containers/configure/types'; import { connectorsConfiguration } from '../connectors'; import * as i18n from './translations'; @@ -20,6 +21,7 @@ export interface Props { onChange: (id: string) => void; selectedConnector: string; appendAddConnectorButton?: boolean; + hideConnectorServiceNowSir?: boolean; } const ICON_SIZE = 'm'; @@ -61,29 +63,36 @@ const ConnectorsDropdownComponent: React.FC = ({ onChange, selectedConnector, appendAddConnectorButton = false, + hideConnectorServiceNowSir = false, }) => { const connectorsAsOptions = useMemo(() => { const connectorsFormatted = connectors.reduce( - (acc, connector) => [ - ...acc, - { - value: connector.id, - inputDisplay: ( - - - - - - {connector.name} - - - ), - 'data-test-subj': `dropdown-connector-${connector.id}`, - }, - ], + (acc, connector) => { + if (hideConnectorServiceNowSir && connector.actionTypeId === ConnectorTypes.serviceNowSIR) { + return acc; + } + + return [ + ...acc, + { + value: connector.id, + inputDisplay: ( + + + + + + {connector.name} + + + ), + 'data-test-subj': `dropdown-connector-${connector.id}`, + }, + ]; + }, [noConnectorOption] ); diff --git a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx b/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx index d5f5530acde9b3..586a7c19cc5324 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx @@ -22,6 +22,7 @@ interface ConnectorSelectorProps { isEdit: boolean; isLoading: boolean; handleChange?: (newValue: string) => void; + hideConnectorServiceNowSir?: boolean; } export const ConnectorSelector = ({ connectors, @@ -32,6 +33,7 @@ export const ConnectorSelector = ({ isEdit = true, isLoading = false, handleChange, + hideConnectorServiceNowSir = false, }: ConnectorSelectorProps) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); const onChange = useCallback( @@ -58,6 +60,7 @@ export const ConnectorSelector = ({ ` - margin-top: ${theme.eui?.euiSize ?? '16px'}; + padding: ${theme.eui?.euiSizeS ?? '8px'} ${theme.eui?.euiSizeL ?? '24px'} ${ + theme.eui?.euiSizeL ?? '24px' + } ${theme.eui?.euiSizeL ?? '24px'}; `} `; const defaultAlertComment = { type: CommentType.generatedAlert, - alerts: `[{{#context.alerts}}{"_id": "{{_id}}", "_index": "{{_index}}", "ruleId": "{{rule.id}}", "ruleName": "{{rule.name}}"}__SEPARATOR__{{/context.alerts}}]`, + alerts: `[{{#context.alerts}}{"_id": "{{_id}}", "_index": "{{_index}}", "ruleId": "{{signal.rule.id}}", "ruleName": "{{signal.rule.name}}"}__SEPARATOR__{{/context.alerts}}]`, }; const CaseParamsFields: React.FunctionComponent> = ({ @@ -90,12 +92,13 @@ const CaseParamsFields: React.FunctionComponent - - - - - + + + + +

{i18n.CASE_CONNECTOR_CALL_OUT_MSG}

+
+
); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx index 5f564d7b62464c..c1013718d57561 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/existing_case.tsx @@ -5,22 +5,15 @@ * 2.0. */ -import { - EuiButton, - EuiButtonIcon, - EuiCallOut, - EuiTextColor, - EuiLoadingSpinner, -} from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import React, { memo, useEffect, useCallback, useState } from 'react'; +import React, { memo, useMemo, useCallback } from 'react'; import { CaseType } from '../../../../../../case/common/api'; -import { Case } from '../../../containers/types'; -import { useDeleteCases } from '../../../containers/use_delete_cases'; -import { useGetCase } from '../../../containers/use_get_case'; -import { ConfirmDeleteCaseModal } from '../../confirm_delete_case'; +import { + useGetCases, + DEFAULT_QUERY_PARAMS, + DEFAULT_FILTER_OPTIONS, +} from '../../../containers/use_get_cases'; import { useCreateCaseModal } from '../../use_create_case_modal'; -import * as i18n from './translations'; +import { CasesDropdown, ADD_CASE_BUTTON_ID } from './cases_dropdown'; interface ExistingCaseProps { selectedCase: string | null; @@ -28,76 +21,53 @@ interface ExistingCaseProps { } const ExistingCaseComponent: React.FC = ({ onCaseChanged, selectedCase }) => { - const { data, isLoading, isError } = useGetCase(selectedCase ?? ''); - const [createdCase, setCreatedCase] = useState(null); + const { data: cases, loading: isLoadingCases, refetchCases } = useGetCases(DEFAULT_QUERY_PARAMS, { + ...DEFAULT_FILTER_OPTIONS, + onlyCollectionType: true, + }); const onCaseCreated = useCallback( - (newCase: Case) => { + (newCase) => { + refetchCases(); onCaseChanged(newCase.id); - setCreatedCase(newCase); }, - [onCaseChanged] + [onCaseChanged, refetchCases] ); - const { modal, openModal } = useCreateCaseModal({ caseType: CaseType.collection, onCaseCreated }); + const { modal, openModal } = useCreateCaseModal({ + onCaseCreated, + caseType: CaseType.collection, + // FUTURE DEVELOPER + // We are making the assumption that this component is only used in rules creation + // that's why we want to hide ServiceNow SIR + hideConnectorServiceNowSir: true, + }); - // Delete case - const { - dispatchResetIsDeleted, - handleOnDeleteConfirm, - handleToggleModal, - isLoading: isDeleting, - isDeleted, - isDisplayConfirmDeleteModal, - } = useDeleteCases(); + const onChange = useCallback( + (id: string) => { + if (id === ADD_CASE_BUTTON_ID) { + openModal(); + return; + } - useEffect(() => { - if (isDeleted) { - setCreatedCase(null); - onCaseChanged(''); - dispatchResetIsDeleted(); - } - // onCaseChanged and/or dispatchResetIsDeleted causes re-renders - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isDeleted]); + onCaseChanged(id); + }, + [onCaseChanged, openModal] + ); - useEffect(() => { - if (!isLoading && !isError && data != null) { - setCreatedCase(data); - onCaseChanged(data.id); - } - // onCaseChanged causes re-renders - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, isLoading, isError]); + const isCasesLoading = useMemo( + () => isLoadingCases.includes('cases') || isLoadingCases.includes('caseUpdate'), + [isLoadingCases] + ); return ( <> - {createdCase == null && isEmpty(selectedCase) && ( - - {i18n.CREATE_CASE} - - )} - {createdCase == null && isLoading && } - {createdCase != null && !isLoading && ( - <> - - - {createdCase.title}{' '} - {!isDeleting && ( - - )} - {isDeleting && } - - - - - )} + {modal} ); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts index 731e94a17d9235..6ce5316d0eb88d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/translations.ts @@ -40,7 +40,7 @@ export const CASE_CONNECTOR_COMMENT_REQUIRED = i18n.translate( export const CASE_CONNECTOR_CASES_DROPDOWN_ROW_LABEL = i18n.translate( 'xpack.securitySolution.case.components.connectors.case.casesDropdownRowLabel', { - defaultMessage: 'Case', + defaultMessage: 'Case allowing sub-cases', } ); @@ -72,10 +72,18 @@ export const CASE_CONNECTOR_CASE_REQUIRED = i18n.translate( } ); -export const CASE_CONNECTOR_CALL_OUT_INFO = i18n.translate( - 'xpack.securitySolution.case.components.connectors.case.callOutInfo', +export const CASE_CONNECTOR_CALL_OUT_TITLE = i18n.translate( + 'xpack.securitySolution.case.components.connectors.case.callOutTitle', { - defaultMessage: 'All alerts after rule creation will be appended to the selected case.', + defaultMessage: 'Generated alerts will be attached to sub-cases', + } +); + +export const CASE_CONNECTOR_CALL_OUT_MSG = i18n.translate( + 'xpack.securitySolution.case.components.connectors.case.callOutMsg', + { + defaultMessage: + 'A case can contain multiple sub-cases to allow grouping of generated alerts. Sub-cases will give more granular control over the status of these generated alerts and prevents having too many alerts attached to one case.', } ); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx index 5e7972aec9d4be..bfe0d8dd78e282 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx @@ -8,6 +8,7 @@ import React, { memo, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { ConnectorTypes } from '../../../../../case/common/api'; import { UseField, useFormData, FieldHook, useFormContext } from '../../../shared_imports'; import { useConnectors } from '../../containers/configure/use_connectors'; import { ConnectorSelector } from '../connector_selector/form'; @@ -18,19 +19,32 @@ import { FormProps } from './schema'; interface Props { isLoading: boolean; + hideConnectorServiceNowSir?: boolean; } interface ConnectorsFieldProps { connectors: ActionConnector[]; field: FieldHook; isEdit: boolean; + hideConnectorServiceNowSir?: boolean; } -const ConnectorFields = ({ connectors, isEdit, field }: ConnectorsFieldProps) => { +const ConnectorFields = ({ + connectors, + isEdit, + field, + hideConnectorServiceNowSir = false, +}: ConnectorsFieldProps) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const { setValue } = field; - const connector = getConnectorById(connectorId, connectors) ?? null; - + let connector = getConnectorById(connectorId, connectors) ?? null; + if ( + connector && + hideConnectorServiceNowSir && + connector.actionTypeId === ConnectorTypes.serviceNowSIR + ) { + connector = null; + } return ( ); }; -const ConnectorComponent: React.FC = ({ isLoading }) => { +const ConnectorComponent: React.FC = ({ hideConnectorServiceNowSir = false, isLoading }) => { const { getFields } = useFormContext(); const { loading: isLoadingConnectors, connectors } = useConnectors(); const handleConnectorChange = useCallback( @@ -61,6 +75,7 @@ const ConnectorComponent: React.FC = ({ isLoading }) => { componentProps={{ connectors, handleChange: handleConnectorChange, + hideConnectorServiceNowSir, dataTestSubj: 'caseConnectors', disabled: isLoading || isLoadingConnectors, idAria: 'caseConnectors', @@ -74,6 +89,7 @@ const ConnectorComponent: React.FC = ({ isLoading }) => { component={ConnectorFields} componentProps={{ connectors, + hideConnectorServiceNowSir, isEdit: true, }} /> diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form.tsx index f5b113ae8e26f3..09518c6f6adc11 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form.tsx @@ -36,78 +36,84 @@ const MySpinner = styled(EuiLoadingSpinner)` `; interface Props { + hideConnectorServiceNowSir?: boolean; withSteps?: boolean; } -export const CreateCaseForm: React.FC = React.memo(({ withSteps = true }) => { - const { isSubmitting } = useFormContext(); +export const CreateCaseForm: React.FC = React.memo( + ({ hideConnectorServiceNowSir = false, withSteps = true }) => { + const { isSubmitting } = useFormContext(); - const firstStep = useMemo( - () => ({ - title: i18n.STEP_ONE_TITLE, - children: ( - <> - + const firstStep = useMemo( + () => ({ + title: i18n.STEP_ONE_TITLE, + children: ( + <> + <Title isLoading={isSubmitting} /> + <Container> + <Tags isLoading={isSubmitting} /> + </Container> + <Container big> + <Description isLoading={isSubmitting} /> + </Container> + </> + ), + }), + [isSubmitting] + ); + + const secondStep = useMemo( + () => ({ + title: i18n.STEP_TWO_TITLE, + children: ( <Container> - <Tags isLoading={isSubmitting} /> - </Container> - <Container big> - <Description isLoading={isSubmitting} /> + <SyncAlertsToggle isLoading={isSubmitting} /> </Container> - </> - ), - }), - [isSubmitting] - ); - - const secondStep = useMemo( - () => ({ - title: i18n.STEP_TWO_TITLE, - children: ( - <Container> - <SyncAlertsToggle isLoading={isSubmitting} /> - </Container> - ), - }), - [isSubmitting] - ); + ), + }), + [isSubmitting] + ); - const thirdStep = useMemo( - () => ({ - title: i18n.STEP_THREE_TITLE, - children: ( - <Container> - <Connector isLoading={isSubmitting} /> - </Container> - ), - }), - [isSubmitting] - ); + const thirdStep = useMemo( + () => ({ + title: i18n.STEP_THREE_TITLE, + children: ( + <Container> + <Connector + hideConnectorServiceNowSir={hideConnectorServiceNowSir} + isLoading={isSubmitting} + /> + </Container> + ), + }), + [hideConnectorServiceNowSir, isSubmitting] + ); - const allSteps = useMemo(() => [firstStep, secondStep, thirdStep], [ - firstStep, - secondStep, - thirdStep, - ]); + const allSteps = useMemo(() => [firstStep, secondStep, thirdStep], [ + firstStep, + secondStep, + thirdStep, + ]); - return ( - <> - {isSubmitting && <MySpinner data-test-subj="create-case-loading-spinner" size="xl" />} - {withSteps ? ( - <EuiSteps - headingElement="h2" - steps={allSteps} - data-test-subj={'case-creation-form-steps'} - /> - ) : ( - <> - {firstStep.children} - {secondStep.children} - {thirdStep.children} - </> - )} - </> - ); -}); + return ( + <> + {isSubmitting && <MySpinner data-test-subj="create-case-loading-spinner" size="xl" />} + {withSteps ? ( + <EuiSteps + headingElement="h2" + steps={allSteps} + data-test-subj={'case-creation-form-steps'} + /> + ) : ( + <> + {firstStep.children} + {secondStep.children} + {thirdStep.children} + </> + )} + </> + ); + } +); CreateCaseForm.displayName = 'CreateCaseForm'; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index 26203d7268fd38..f56dcafdc95e4d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -19,7 +19,7 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service' import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Case } from '../../containers/types'; -import { CaseType } from '../../../../../case/common/api'; +import { CaseType, ConnectorTypes } from '../../../../../case/common/api'; const initialCaseValue: FormProps = { description: '', @@ -31,29 +31,40 @@ const initialCaseValue: FormProps = { }; interface Props { + afterCaseCreated?: (theCase: Case) => Promise<void>; caseType?: CaseType; + hideConnectorServiceNowSir?: boolean; onSuccess?: (theCase: Case) => Promise<void>; - afterCaseCreated?: (theCase: Case) => Promise<void>; } export const FormContext: React.FC<Props> = ({ + afterCaseCreated, caseType = CaseType.individual, children, + hideConnectorServiceNowSir, onSuccess, - afterCaseCreated, }) => { const { connectors } = useConnectors(); const { connector: configurationConnector } = useCaseConfigure(); const { postCase } = usePostCase(); const { pushCaseToExternalService } = usePostPushToService(); - const connectorId = useMemo( - () => - connectors.some((connector) => connector.id === configurationConnector.id) - ? configurationConnector.id - : 'none', - [configurationConnector.id, connectors] - ); + const connectorId = useMemo(() => { + if ( + hideConnectorServiceNowSir && + configurationConnector.type === ConnectorTypes.serviceNowSIR + ) { + return 'none'; + } + return connectors.some((connector) => connector.id === configurationConnector.id) + ? configurationConnector.id + : 'none'; + }, [ + configurationConnector.id, + configurationConnector.type, + connectors, + hideConnectorServiceNowSir, + ]); const submitCase = useCallback( async ( diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx index 34dcacaf42a981..d0f478dc17f81f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx @@ -34,7 +34,6 @@ import * as i18n from './translations'; interface EditConnectorProps { caseFields: ConnectorTypeFields['fields']; connectors: ActionConnector[]; - disabled?: boolean; isLoading: boolean; onSubmit: ( connectorId: string, @@ -44,6 +43,8 @@ interface EditConnectorProps { ) => void; selectedConnector: string; userActions: CaseUserActions[]; + disabled?: boolean; + hideConnectorServiceNowSir?: boolean; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -105,6 +106,7 @@ export const EditConnector = React.memo( caseFields, connectors, disabled = false, + hideConnectorServiceNowSir = false, isLoading, onSubmit, selectedConnector, @@ -234,6 +236,7 @@ export const EditConnector = React.memo( dataTestSubj: 'caseConnectors', defaultValue: selectedConnector, disabled, + hideConnectorServiceNowSir, idAria: 'caseConnectors', isEdit: editConnector, isLoading, diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx index 3e11ee526839cd..b1edaa56cd3482 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx @@ -21,6 +21,7 @@ export interface CreateCaseModalProps { onCloseCaseModal: () => void; onSuccess: (theCase: Case) => Promise<void>; caseType?: CaseType; + hideConnectorServiceNowSir?: boolean; } const Container = styled.div` @@ -35,6 +36,7 @@ const CreateModalComponent: React.FC<CreateCaseModalProps> = ({ onCloseCaseModal, onSuccess, caseType = CaseType.individual, + hideConnectorServiceNowSir = false, }) => { return isModalOpen ? ( <EuiModal onClose={onCloseCaseModal} data-test-subj="all-cases-modal"> @@ -42,8 +44,15 @@ const CreateModalComponent: React.FC<CreateCaseModalProps> = ({ <EuiModalHeaderTitle>{i18n.CREATE_TITLE}</EuiModalHeaderTitle> </EuiModalHeader> <EuiModalBody> - <FormContext caseType={caseType} onSuccess={onSuccess}> - <CreateCaseForm withSteps={false} /> + <FormContext + hideConnectorServiceNowSir={hideConnectorServiceNowSir} + caseType={caseType} + onSuccess={onSuccess} + > + <CreateCaseForm + withSteps={false} + hideConnectorServiceNowSir={hideConnectorServiceNowSir} + /> <Container> <SubmitCaseButton /> </Container> diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx index 1cef63ae9cfbf8..50887f08dee6e4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/index.tsx @@ -13,6 +13,7 @@ import { CreateCaseModal } from './create_case_modal'; export interface UseCreateCaseModalProps { onCaseCreated: (theCase: Case) => void; caseType?: CaseType; + hideConnectorServiceNowSir?: boolean; } export interface UseCreateCaseModalReturnedValues { modal: JSX.Element; @@ -24,6 +25,7 @@ export interface UseCreateCaseModalReturnedValues { export const useCreateCaseModal = ({ caseType = CaseType.individual, onCaseCreated, + hideConnectorServiceNowSir = false, }: UseCreateCaseModalProps) => { const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const closeModal = useCallback(() => setIsModalOpen(false), []); @@ -41,6 +43,7 @@ export const useCreateCaseModal = ({ modal: ( <CreateCaseModal caseType={caseType} + hideConnectorServiceNowSir={hideConnectorServiceNowSir} isModalOpen={isModalOpen} onCloseCaseModal={closeModal} onSuccess={onSuccess} @@ -50,7 +53,7 @@ export const useCreateCaseModal = ({ closeModal, openModal, }), - [caseType, closeModal, isModalOpen, onSuccess, openModal] + [caseType, closeModal, hideConnectorServiceNowSir, isModalOpen, onSuccess, openModal] ); return state; diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/security_solution/public/cases/containers/api.ts index c87e210b42bc05..01ef040aa19cda 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -15,6 +15,7 @@ import { CasesResponse, CasesStatusResponse, CaseStatuses, + CaseType, CaseUserActionsResponse, CommentRequest, CommentType, @@ -165,6 +166,7 @@ export const getSubCaseUserActions = async ( export const getCases = async ({ filterOptions = { + onlyCollectionType: false, search: '', reporters: [], status: CaseStatuses.open, @@ -183,6 +185,7 @@ export const getCases = async ({ tags: filterOptions.tags.map((t) => `"${t.replace(/"/g, '\\"')}"`), status: filterOptions.status, ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}), + ...(filterOptions.onlyCollectionType ? { type: CaseType.collection } : {}), ...queryParams, }; const response = await KibanaServices.get().http.fetch<CasesFindResponse>(`${CASES_URL}/_find`, { diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index d2931a790bd798..399d8d43ce0655 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -99,6 +99,7 @@ export interface FilterOptions { status: CaseStatuses; tags: string[]; reporters: User[]; + onlyCollectionType?: boolean; } export interface CasesStatus { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx index c83cc02dedb977..f2e8e280bf158c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_cases.tsx @@ -97,6 +97,7 @@ export const DEFAULT_FILTER_OPTIONS: FilterOptions = { reporters: [], status: CaseStatuses.open, tags: [], + onlyCollectionType: false, }; export const DEFAULT_QUERY_PARAMS: QueryParams = { @@ -129,10 +130,13 @@ export interface UseGetCases extends UseGetCasesState { setSelectedCases: (mySelectedCases: Case[]) => void; } -export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { +export const useGetCases = ( + initialQueryParams?: QueryParams, + initialFilterOptions?: FilterOptions +): UseGetCases => { const [state, dispatch] = useReducer(dataFetchReducer, { data: initialData, - filterOptions: DEFAULT_FILTER_OPTIONS, + filterOptions: initialFilterOptions ?? DEFAULT_FILTER_OPTIONS, isError: false, loading: [], queryParams: initialQueryParams ?? DEFAULT_QUERY_PARAMS, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx index 7563c8d8f99f07..5dbe1f1cef5be7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.test.tsx @@ -8,10 +8,11 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { RuleActionsField } from './index'; +import { getSupportedActions, RuleActionsField } from './index'; import { useForm, Form } from '../../../../shared_imports'; import { useKibana } from '../../../../common/lib/kibana'; import { useFormFieldMock } from '../../../../common/mock'; +import { ActionType } from '../../../../../../actions/common'; jest.mock('../../../../common/lib/kibana'); describe('RuleActionsField', () => { @@ -45,7 +46,11 @@ describe('RuleActionsField', () => { return ( <Form form={form}> - <RuleActionsField field={field} messageVariables={messageVariables} /> + <RuleActionsField + field={field} + messageVariables={messageVariables} + hasErrorOnCreationCaseAction={false} + /> </Form> ); }; @@ -53,4 +58,63 @@ describe('RuleActionsField', () => { expect(wrapper.dive().find('ActionForm')).toHaveLength(0); }); + + describe('#getSupportedActions', () => { + const actions: ActionType[] = [ + { + id: '.jira', + name: 'My Jira', + enabled: true, + enabledInConfig: false, + enabledInLicense: true, + minimumLicenseRequired: 'gold', + }, + { + id: '.case', + name: 'Cases', + enabled: true, + enabledInConfig: false, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + ]; + + it('if we have an error on case action creation, we do not support case connector', () => { + expect(getSupportedActions(actions, true)).toMatchInlineSnapshot(` + Array [ + Object { + "enabled": true, + "enabledInConfig": false, + "enabledInLicense": true, + "id": ".jira", + "minimumLicenseRequired": "gold", + "name": "My Jira", + }, + ] + `); + }); + + it('if we do NOT have an error on case action creation, we are supporting case connector', () => { + expect(getSupportedActions(actions, false)).toMatchInlineSnapshot(` + Array [ + Object { + "enabled": true, + "enabledInConfig": false, + "enabledInLicense": true, + "id": ".jira", + "minimumLicenseRequired": "gold", + "name": "My Jira", + }, + Object { + "enabled": true, + "enabledInConfig": false, + "enabledInLicense": true, + "id": ".case", + "minimumLicenseRequired": "basic", + "name": "Cases", + }, + ] + `); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx index cee85df5db4367..9fd9e910ee0f8f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_actions_field/index.tsx @@ -26,6 +26,7 @@ import { FORM_ERRORS_TITLE } from './translations'; interface Props { field: FieldHook; + hasErrorOnCreationCaseAction: boolean; messageVariables: ActionVariables; } @@ -39,7 +40,44 @@ const FieldErrorsContainer = styled.div` } `; -export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) => { +const ContainerActions = styled.div.attrs( + ({ className = '', $caseIndexes = [] }: { className?: string; $caseIndexes: string[] }) => ({ + className, + }) +)<{ $caseIndexes: string[] }>` + ${({ $caseIndexes }) => + $caseIndexes.map( + (index) => ` + div[id="${index}"].euiAccordion__childWrapper .euiAccordion__padding--l { + padding: 0px; + .euiFlexGroup { + display: none; + } + .euiSpacer.euiSpacer--xl { + height: 0px; + } + } + ` + )} +`; + +export const getSupportedActions = ( + actionTypes: ActionType[], + hasErrorOnCreationCaseAction: boolean +): ActionType[] => { + return actionTypes.filter((actionType) => { + if (actionType.id === '.case' && hasErrorOnCreationCaseAction) { + return false; + } + return NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.includes(actionType.id); + }); +}; + +export const RuleActionsField: React.FC<Props> = ({ + field, + hasErrorOnCreationCaseAction, + messageVariables, +}) => { const [fieldErrors, setFieldErrors] = useState<string | null>(null); const [supportedActionTypes, setSupportedActionTypes] = useState<ActionType[] | undefined>(); const form = useFormContext(); @@ -54,6 +92,17 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) = [field.value] ); + const caseActionIndexes = useMemo( + () => + actions.reduce<string[]>((acc, action, actionIndex) => { + if (action.actionTypeId === '.case') { + return [...acc, `${actionIndex}`]; + } + return acc; + }, []), + [actions] + ); + const setActionIdByIndex = useCallback( (id: string, index: number) => { const updatedActions = [...(actions as Array<Partial<AlertAction>>)]; @@ -83,13 +132,11 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) = useEffect(() => { (async function () { const actionTypes = await loadActionTypes({ http }); - const supportedTypes = actionTypes.filter((actionType) => - NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.includes(actionType.id) - ); + const supportedTypes = getSupportedActions(actionTypes, hasErrorOnCreationCaseAction); setSupportedActionTypes(supportedTypes); })(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [hasErrorOnCreationCaseAction]); useEffect(() => { if (isSubmitting || !field.errors.length) { @@ -104,7 +151,7 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) = if (!supportedActionTypes) return <></>; return ( - <> + <ContainerActions $caseIndexes={caseActionIndexes}> {fieldErrors ? ( <> <FieldErrorsContainer> @@ -126,6 +173,6 @@ export const RuleActionsField: React.FC<Props> = ({ field, messageVariables }) = actionTypes={supportedActionTypes} defaultActionMessage={DEFAULT_ACTION_MESSAGE} /> - </> + </ContainerActions> ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 30898cdeca4a3b..a31371c31cbbb5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -36,6 +36,7 @@ import { useKibana } from '../../../../common/lib/kibana'; import { getSchema } from './schema'; import * as I18n from './translations'; import { APP_ID } from '../../../../../common/constants'; +import { useManageCaseAction } from './use_manage_case_action'; interface StepRuleActionsProps extends RuleStepProps { defaultValues?: ActionsStepRule | null; @@ -70,6 +71,7 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({ setForm, actionMessageParams, }) => { + const [isLoadingCaseAction, hasErrorOnCreationCaseAction] = useManageCaseAction(); const { services: { application, @@ -138,13 +140,14 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({ () => ({ idAria: 'detectionEngineStepRuleActionsThrottle', isDisabled: isLoading, + isLoading: isLoadingCaseAction, dataTestSubj: 'detectionEngineStepRuleActionsThrottle', hasNoInitialSelection: false, euiFieldProps: { options: throttleOptions, }, }), - [isLoading, throttleOptions] + [isLoading, isLoadingCaseAction, throttleOptions] ); const displayActionsOptions = useMemo( @@ -157,13 +160,14 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({ component={RuleActionsField} componentProps={{ messageVariables: actionMessageParams, + hasErrorOnCreationCaseAction, }} /> </> ) : ( <UseField path="actions" component={GhostFormField} /> ), - [throttle, actionMessageParams] + [throttle, actionMessageParams, hasErrorOnCreationCaseAction] ); // only display the actions dropdown if the user has "read" privileges for actions const displayActionsDropDown = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx new file mode 100644 index 00000000000000..55b2aefe213106 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/use_manage_case_action.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useRef, useState } from 'react'; +import { ACTION_URL } from '../../../../../../case/common/constants'; +import { KibanaServices } from '../../../../common/lib/kibana'; + +interface CaseAction { + actionTypeId: string; + id: string; + isPreconfigured: boolean; + name: string; + referencedByCount: number; +} + +const CASE_ACTION_NAME = 'Cases'; + +export const useManageCaseAction = () => { + const hasInit = useRef(true); + const [loading, setLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + const abortCtrl = new AbortController(); + const fetchActions = async () => { + try { + const actions = await KibanaServices.get().http.fetch<CaseAction[]>(ACTION_URL, { + method: 'GET', + signal: abortCtrl.signal, + }); + if (!actions.some((a) => a.actionTypeId === '.case' && a.name === CASE_ACTION_NAME)) { + await KibanaServices.get().http.post<CaseAction[]>(`${ACTION_URL}/action`, { + method: 'POST', + body: JSON.stringify({ + actionTypeId: '.case', + config: {}, + name: CASE_ACTION_NAME, + secrets: {}, + }), + signal: abortCtrl.signal, + }); + } + setLoading(false); + } catch { + setLoading(false); + setHasError(true); + } + }; + if (hasInit.current) { + hasInit.current = false; + fetchActions(); + } + + return () => { + abortCtrl.abort(); + }; + }, []); + return [loading, hasError]; +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index dd438da1d80d56..a2213b76e72711 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17208,7 +17208,6 @@ "xpack.securitySolution.case.common.noConnector": "コネクターを選択していません", "xpack.securitySolution.case.components.connectors.case.actionTypeTitle": "ケース", "xpack.securitySolution.case.components.connectors.case.addNewCaseOption": "新規ケースの追加", - "xpack.securitySolution.case.components.connectors.case.callOutInfo": "ルールを作成した後のすべてのアラートは、選択したケースの最後に追加されます。", "xpack.securitySolution.case.components.connectors.case.caseRequired": "ケースの選択が必要です。", "xpack.securitySolution.case.components.connectors.case.casesDropdownPlaceholder": "ケースを選択", "xpack.securitySolution.case.components.connectors.case.casesDropdownRowLabel": "ケース", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 391b1f68086d41..da5ec5d185c40b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17251,7 +17251,6 @@ "xpack.securitySolution.case.common.noConnector": "未选择任何连接器", "xpack.securitySolution.case.components.connectors.case.actionTypeTitle": "案例", "xpack.securitySolution.case.components.connectors.case.addNewCaseOption": "添加新案例", - "xpack.securitySolution.case.components.connectors.case.callOutInfo": "规则创建后的所有告警将追加到选定案例。", "xpack.securitySolution.case.components.connectors.case.caseRequired": "必须选择策略。", "xpack.securitySolution.case.components.connectors.case.casesDropdownPlaceholder": "选择案例", "xpack.securitySolution.case.components.connectors.case.casesDropdownRowLabel": "案例",