diff --git a/docs/CHANGELOG.asciidoc b/docs/CHANGELOG.asciidoc index 9be0170b67b74..b2ce650a531dc 100644 --- a/docs/CHANGELOG.asciidoc +++ b/docs/CHANGELOG.asciidoc @@ -72,10 +72,10 @@ You must migrate your time_based index patterns to a wildcard pattern. For examp [%collapsible] ==== *Details* + -For the `.tar.gz` and `.zip` archives, `platform` has been removed from the `root` folder name. For more information, refer to {kibana-pull}93835[#93835] +After you extract an archive, the output directory no longer includes the target platform. For example, `kibana-8.0.0-linux-aarch64.tar.gz` produces a `kibana-8.0.0` folder. For more information, refer to {kibana-pull}93835[#93835]. *Impact* + -The `root` folder name now appears as `kibana-8.0.0-SNAPSHOT-linux-aarch64.tar.gz -> kibana-8.0.0-SNAPSHOT`. +To use the new folder, update the configuration management tools and automation. ==== [discrete] @@ -84,10 +84,10 @@ The `root` folder name now appears as `kibana-8.0.0-SNAPSHOT-linux-aarch64.tar.g [%collapsible] ==== *Details* + -The default support for TLS v1.0 and v1.1 has been removed. For more information, refer to {kibana-pull}90511[#90511] +The default support for TLS v1.0 and v1.1 has been removed. For more information, refer to {kibana-pull}90511[#90511]. *Impact* + -To enable support, set the environment variable to `NODE_OPTIONS=--tls-min-1.0`. +To enable support, set `--tls-min-1.0` in the `node.options` configuration file. To locate the configuration file, go to the kibana/config folder or any other configuration with the `KBN_PATH_CONF` environment variable. For example, if you are using a Debian-based system, the configuration file is located in /etc/kibana. ==== [discrete] @@ -96,10 +96,10 @@ To enable support, set the environment variable to `NODE_OPTIONS=--tls-min-1.0`. [%collapsible] ==== *Details* + -Systems that don't have `service` aliased to use kibana.service are unable to use `service start kibana`. For more information, refer to {kibana-pull}74424[#74424] +All supported operating systems use systemd service files. Any system that doesn’t have `service` aliased to use kibana.service should use `systemctl start kibana.service` instead of `service start kibana`. For more information, refer to {kibana-pull}74424[#74424]. *Impact* + -If your system doesn't have `service` aliased to use kibana.service, use `systemctl start kibana.service`. +If your installation uses .deb or .rpm packages with SysV, migrate to systemd. ==== [discrete] @@ -108,10 +108,30 @@ If your system doesn't have `service` aliased to use kibana.service, use `system [%collapsible] ==== *Details* + -By default, responses are not logged. Previously, responses were logged if `logging.json` was set to `true`, `logging.dest` was specified, or a TTY was detected. For more information, refer to {kibana-pull}42353[#42353] +In previous versions, all events are logged in `json` when `logging.json:true`. With the new logging configuration, you can choose the `json` and pattern output formats with layouts. For more information, refer to {kibana-pull}42353[#42353]. *Impact* + -To log responses, set `logging.events.response=*` in kibana.yml. +To restore the previous behavior, configure the logging format for each custom appender with the `appender.layout property` in kibana.yml. There is no default for custom appenders, and each appender must be configured expilictly. + +[source,yaml] +------------------- +logging: + appenders: + custom_console: + type: console + layout: + type: pattern + custom_json: + type: console + layout: + type: json + loggers: + - name: plugins.myPlugin + appenders: [custom_console] + root: + appenders: [default, custom_json] + level: warn +------------------- ==== [float] @@ -120,7 +140,7 @@ To log responses, set `logging.events.response=*` in kibana.yml. [discrete] [[breaking-52539]] -.Removed legacy Reporting job params compatibility shim +.Legacy job parameters are no longer supported [%collapsible] ==== *Details* + @@ -152,10 +172,10 @@ Use the `/api/security/saml/callback` route, or wait to upgrade to 8.0.0-alpha2 [%collapsible] ==== *Details* + -To provide the maximum level of protection for most installations, the csp.strict config is now enabled by default. Legacy browsers not supported by Kibana, such as IE11, are unable to access {kib} unless explicitly enabled. All browsers officially supported by Kibana do not have this issue. For more information, refer to {kibana-pull}41700[#41700] +To provide the maximum level of protection for most installations, the csp.strict config is now enabled by default. Legacy browsers not supported by Kibana, such as Internet Explorer 11, are unable to access {kib} unless explicitly enabled. All browsers officially supported by Kibana do not have this issue. For more information, refer to {kibana-pull}41700[#41700] *Impact* + -To enable support for legacy browsers, set `csp.strict: false` in kibana.yml. +To enable support for legacy browsers, set `csp.strict: false` in kibana.yml. To effectively enforce the security protocol, we strongly discourage disabling `csp.strict` unless it is critical that you support Internet Explorer 11. ==== [float] @@ -191,14 +211,14 @@ You are now unable to use `0` as the `server.host`. [discrete] [[breaking-38657]] -.Removed `xpack.security.authProviders` and `xpack.security.public` +.Removed `xpack.security.public` and `xpack.security.authProviders` [%collapsible] ==== *Details* + The `xpack.security.public` and `xpack.security.authProviders` settings have been removed. For more information, refer to {kibana-pull}38657[#38657] *Impact* + -Use the `xpack.security.authc.saml.realm` setting. +Use the `xpack.security.authc.saml.realm` and `xpack.security.authc.providers` settings. ==== [discrete] diff --git a/docs/index.asciidoc b/docs/index.asciidoc index 70e40576fdd71..bd3e36620611d 100644 --- a/docs/index.asciidoc +++ b/docs/index.asciidoc @@ -4,8 +4,6 @@ :include-xpack: true :lang: en :kib-repo-dir: {kibana-root}/docs -:blog-ref: https://www.elastic.co/blog/ -:wikipedia: https://en.wikipedia.org/wiki include::{docs-root}/shared/versions/stack/{source_branch}.asciidoc[] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 4bcbdd7843671..c9847effd5f49 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -41,14 +41,15 @@ You can configure the following settings in the `kibana.yml` file. [cols="2*<"] |=== | `xpack.actions.enabled` - | Feature toggle that enables Actions in {kib}. Default: `true`. + | Feature toggle that enables Actions in {kib}. + If `false`, all features dependent on Actions are disabled, including the *Observability* and *Security* apps. Default: `true`. | `xpack.actions.allowedHosts` {ess-icon} | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list `[]` can be used to block built-in actions from making any external connections. + + Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically added to allowed hosts. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are added to the allowed hosts as well. - -| `xpack.actions.customHostSettings` {ess-icon} + +| `xpack.actions.customHostSettings` {ess-icon} | A list of custom host settings to override existing global settings. Default: an empty list. + + @@ -69,7 +70,7 @@ You can configure the following settings in the `kibana.yml` file. -- xpack.actions.customHostSettings: - url: smtp://mail.example.com:465 - ssl: + ssl: verificationMode: 'full' certificateAuthoritiesFiles: [ 'one.crt' ] certificateAuthoritiesData: | @@ -79,7 +80,7 @@ xpack.actions.customHostSettings: smtp: requireTLS: true - url: https://webhook.example.com - ssl: + ssl: // legacy rejectUnauthorized: false verificationMode: 'none' @@ -124,7 +125,7 @@ xpack.actions.customHostSettings: |[[action-config-custom-host-verification-mode]] `xpack.actions.customHostSettings[n]` `.ssl.verificationMode` {ess-icon} - | Controls the verification of the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the host server. Valid values are `full`, `certificate`, and `none`. + | Controls the verification of the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the host server. Valid values are `full`, `certificate`, and `none`. Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. Overrides the general `xpack.actions.ssl.verificationMode` configuration for requests made for this hostname/port. @@ -137,7 +138,7 @@ xpack.actions.customHostSettings: `.ssl.certificateAuthoritiesData` {ess-icon} | The contents of a PEM-encoded certificate file, or multiple files appended into a single string. This configuration can be used for environments where - the files cannot be made available. + the files cannot be made available. | `xpack.actions.enabledActionTypes` {ess-icon} | A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. + @@ -170,7 +171,7 @@ a|`xpack.actions.` |[[action-config-proxy-verification-mode]] `xpack.actions[n]` `.ssl.proxyVerificationMode` {ess-icon} -| Controls the verification for the proxy server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the proxy server. Valid values are `full`, `certificate`, and `none`. +| Controls the verification for the proxy server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection to the proxy server. Valid values are `full`, `certificate`, and `none`. Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. | `xpack.actions.rejectUnauthorized` {ess-icon} @@ -182,7 +183,7 @@ Use `full` to perform hostname verification, `certificate` to skip hostname veri |[[action-config-verification-mode]] `xpack.actions[n]` `.ssl.verificationMode` {ess-icon} -| Controls the verification for the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection for actions. Valid values are `full`, `certificate`, and `none`. +| Controls the verification for the server certificate that {hosted-ems} receives when making an outbound SSL/TLS connection for actions. Valid values are `full`, `certificate`, and `none`. Use `full` to perform hostname verification, `certificate` to skip hostname verification, and `none` to skip verification. Default: `full`. <>. + + As an alternative to setting `xpack.actions.ssl.verificationMode`, you can use the setting @@ -213,4 +214,4 @@ Use `full` to perform hostname verification, `certificate` to skip hostname veri | `xpack.alerting.maxEphemeralActionsPerAlert` | Sets the number of actions that will be executed ephemerally. To use this, enable ephemeral tasks in task manager first with <> -|=== \ No newline at end of file +|=== diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index a72eda5bb1207..37a491cdad4c0 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -11,7 +11,7 @@ import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt, CaseStatusRt } from './status'; -import { CaseConnectorRt, ESCaseConnector } from '../connectors'; +import { CaseConnectorRt } from '../connectors'; import { SubCaseResponseRt } from './sub_case'; const BucketsAggs = rt.array( @@ -87,24 +87,17 @@ const CaseBasicRt = rt.type({ owner: rt.string, }); -const CaseExternalServiceBasicRt = rt.type({ - connector_id: rt.string, +export const CaseExternalServiceBasicRt = rt.type({ + connector_id: rt.union([rt.string, rt.null]), connector_name: rt.string, external_id: rt.string, external_title: rt.string, external_url: rt.string, + pushed_at: rt.string, + pushed_by: UserRT, }); -const CaseFullExternalServiceRt = rt.union([ - rt.intersection([ - CaseExternalServiceBasicRt, - rt.type({ - pushed_at: rt.string, - pushed_by: UserRT, - }), - ]), - rt.null, -]); +const CaseFullExternalServiceRt = rt.union([CaseExternalServiceBasicRt, rt.null]); export const CaseAttributesRt = rt.intersection([ CaseBasicRt, @@ -326,11 +319,6 @@ export type CaseFullExternalService = rt.TypeOf; export type ExternalServiceResponse = rt.TypeOf; -export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; -export type ESCasePatchRequest = Omit & { - connector?: ESCaseConnector; -}; - export type AllTagsFindRequest = rt.TypeOf; export type AllReportersFindRequest = AllTagsFindRequest; diff --git a/x-pack/plugins/cases/common/api/cases/configure.ts b/x-pack/plugins/cases/common/api/cases/configure.ts index 6c92702c523b4..bf67624df8508 100644 --- a/x-pack/plugins/cases/common/api/cases/configure.ts +++ b/x-pack/plugins/cases/common/api/cases/configure.ts @@ -8,7 +8,7 @@ import * as rt from 'io-ts'; import { UserRT } from '../user'; -import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors'; +import { CaseConnectorRt, ConnectorMappingsRt } from '../connectors'; // TODO: we will need to add this type rt.literal('close-by-third-party') const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); @@ -83,8 +83,4 @@ export type CasesConfigureAttributes = rt.TypeOf; export type CasesConfigurationsResponse = rt.TypeOf; -export type ESCasesConfigureAttributes = Omit & { - connector: ESCaseConnector; -}; - export type GetConfigureFindRequest = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/connectors/index.ts b/x-pack/plugins/cases/common/api/connectors/index.ts index cee432b17933b..77af90b5d08cb 100644 --- a/x-pack/plugins/cases/common/api/connectors/index.ts +++ b/x-pack/plugins/cases/common/api/connectors/index.ts @@ -73,6 +73,8 @@ const ConnectorNoneTypeFieldsRt = rt.type({ fields: rt.null, }); +export const noneConnectorId: string = 'none'; + export const ConnectorTypeFieldsRt = rt.union([ ConnectorJiraTypeFieldsRt, ConnectorNoneTypeFieldsRt, @@ -102,16 +104,3 @@ export type ConnectorServiceNowSIRTypeFields = rt.TypeOf; - -export type ESConnectorFields = Array<{ - key: string; - value: unknown; -}>; - -export type ESCaseConnectorTypes = ConnectorTypes; -export interface ESCaseConnector { - id: string; - name: string; - type: ESCaseConnectorTypes; - fields: ESConnectorFields | null; -} diff --git a/x-pack/plugins/cases/kibana.json b/x-pack/plugins/cases/kibana.json index 6439f28b958d0..f72f0e012bd80 100644 --- a/x-pack/plugins/cases/kibana.json +++ b/x-pack/plugins/cases/kibana.json @@ -1,7 +1,7 @@ { "configPath":[ - "cases", - "xpack" + "xpack", + "cases" ], "description":"The Case management system in Kibana", "extraPublicDirs":[ diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx index 47c683becb244..0e548fd53c89d 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.test.tsx @@ -7,20 +7,27 @@ import React from 'react'; import { mount } from 'enzyme'; -import { AllCasesGeneric } from './all_cases_generic'; +import { act } from 'react-dom/test-utils'; +import { AllCasesGeneric } from './all_cases_generic'; import { TestProviders } from '../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; import { useGetActionLicense } from '../../containers/use_get_action_license'; +import { useConnectors } from '../../containers/configure/use_connectors'; +import { useKibana } from '../../common/lib/kibana'; import { StatusAll } from '../../containers/types'; import { CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../../common'; -import { act } from 'react-dom/test-utils'; +import { connectorsMock } from '../../containers/mock'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/use_get_action_license'); +jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/api'); +jest.mock('../../common/lib/kibana'); const createCaseNavigation = { href: '', onClick: jest.fn() }; @@ -34,26 +41,34 @@ const alertDataMock = { alertId: 'alert-id', owner: SECURITY_SOLUTION_OWNER, }; + +const useKibanaMock = useKibana as jest.Mocked; +const useConnectorsMock = useConnectors as jest.Mock; +const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); + jest.mock('../../common/lib/kibana', () => { const originalModule = jest.requireActual('../../common/lib/kibana'); return { ...originalModule, useKibana: () => ({ services: { - triggersActionsUi: { - actionTypeRegistry: { - get: jest.fn().mockReturnValue({ - actionTypeTitle: '.jira', - iconClass: 'logoSecurity', - }), - }, - }, + triggersActionsUi: mockTriggersActionsUiService, }, }), }; }); describe('AllCasesGeneric ', () => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + + beforeAll(() => { + connectorsMock.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); + }); + beforeEach(() => { jest.resetAllMocks(); (useGetTags as jest.Mock).mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags: jest.fn() }); @@ -68,6 +83,7 @@ describe('AllCasesGeneric ', () => { actionLicense: null, isLoading: false, }); + useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); }); it('renders the first available status when hiddenStatus is given', () => diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx index 477ea27be99bf..72491a2bc1e31 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_generic.tsx @@ -37,6 +37,7 @@ import { CasesTableFilters } from './table_filters'; import { EuiBasicTableOnChange } from './types'; import { CasesTable } from './table'; +import { useConnectors } from '../../containers/configure/use_connectors'; const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => @@ -103,6 +104,7 @@ export const AllCasesGeneric = React.memo( // Post Comment to Case const { postComment, isLoading: isCommentUpdating } = usePostComment(); + const { connectors } = useConnectors({ toastPermissionsErrors: false }); const sorting = useMemo( () => ({ @@ -203,6 +205,7 @@ export const AllCasesGeneric = React.memo( refreshCases, showActions, userCanCrud, + connectors, }); const itemIdToExpandedRowMap = useMemo( diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx index 0f0189f2d29c2..015ba877a2749 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.test.tsx @@ -10,51 +10,69 @@ import { mount } from 'enzyme'; import '../../common/mock/match_media'; import { ExternalServiceColumn } from './columns'; - import { useGetCasesMockState } from '../../containers/mock'; +import { useKibana } from '../../common/lib/kibana'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { connectors } from '../configure_cases/__mock__'; -jest.mock('../../common/lib/kibana', () => { - const originalModule = jest.requireActual('../../common/lib/kibana'); - return { - ...originalModule, - useKibana: () => ({ - services: { - triggersActionsUi: { - actionTypeRegistry: { - get: jest.fn().mockReturnValue({ - actionTypeTitle: '.jira', - iconClass: 'logoSecurity', - }), - }, - }, - }, - }), - }; -}); +jest.mock('../../common/lib/kibana'); +const useKibanaMock = useKibana as jest.Mocked; describe('ExternalServiceColumn ', () => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + + beforeAll(() => { + connectors.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); + }); + it('Not pushed render', () => { const wrapper = mount( - + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-notPushed"]`).last().exists() ).toBeTruthy(); }); + it('Up to date', () => { const wrapper = mount( - + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-upToDate"]`).last().exists() ).toBeTruthy(); }); + it('Needs update', () => { const wrapper = mount( - + ); expect( wrapper.find(`[data-test-subj="case-table-column-external-requiresUpdate"]`).last().exists() ).toBeTruthy(); }); + + it('it does not throw when accessing the icon if the connector type is not registered', () => { + // If the component throws the test will fail + expect(() => + mount( + + ) + ).not.toThrowError(); + }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index 140dbf2f53c25..8b755b0c60968 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -21,7 +21,14 @@ import { import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; -import { CaseStatuses, CaseType, DeleteCase, Case, SubCase } from '../../../common'; +import { + CaseStatuses, + CaseType, + DeleteCase, + Case, + SubCase, + ActionConnector, +} from '../../../common'; import { getEmptyTagValue } from '../empty_value'; import { FormattedRelativePreferenceDate } from '../formatted_date'; import { CaseDetailsHrefSchema, CaseDetailsLink, CasesNavigation } from '../links'; @@ -35,6 +42,7 @@ import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { useKibana } from '../../common/lib/kibana'; import { StatusContextMenu } from '../case_action_bar/status_context_menu'; import { TruncatedText } from '../truncated_text'; +import { getConnectorIcon } from '../utils'; export type CasesColumns = | EuiTableActionsColumnType @@ -66,6 +74,7 @@ export interface GetCasesColumn { refreshCases?: (a?: boolean) => void; showActions: boolean; userCanCrud: boolean; + connectors?: ActionConnector[]; } export const useCasesColumns = ({ caseDetailsNavigation, @@ -77,6 +86,7 @@ export const useCasesColumns = ({ refreshCases, showActions, userCanCrud, + connectors = [], }: GetCasesColumn): CasesColumns[] => { // Delete case const { @@ -266,7 +276,7 @@ export const useCasesColumns = ({ name: i18n.EXTERNAL_INCIDENT, render: (theCase: Case) => { if (theCase.id != null) { - return ; + return ; } return getEmptyTagValue(); }, @@ -325,6 +335,7 @@ export const useCasesColumns = ({ interface Props { theCase: Case; + connectors: ActionConnector[]; } const IconWrapper = styled.span` @@ -335,26 +346,31 @@ const IconWrapper = styled.span` width: 20px !important; } `; -export const ExternalServiceColumn: React.FC = ({ theCase }) => { + +export const ExternalServiceColumn: React.FC = ({ theCase, connectors }) => { const { triggersActionsUi } = useKibana().services; if (theCase.externalService == null) { return renderStringField(i18n.NOT_PUSHED, `case-table-column-external-notPushed`); } + const lastPushedConnector: ActionConnector | undefined = connectors.find( + (connector) => connector.id === theCase.externalService?.connectorId + ); const lastCaseUpdate = theCase.updatedAt != null ? new Date(theCase.updatedAt) : null; const lastCasePush = theCase.externalService?.pushedAt != null ? new Date(theCase.externalService?.pushedAt) : null; const hasDataToPush = lastCasePush === null || (lastCaseUpdate != null && lastCasePush.getTime() < lastCaseUpdate?.getTime()); + return (

; +const useConnectorsMock = useConnectors as jest.Mock; + +const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); jest.mock('../../common/lib/kibana', () => { const originalModule = jest.requireActual('../../common/lib/kibana'); @@ -42,14 +58,7 @@ jest.mock('../../common/lib/kibana', () => { ...originalModule, useKibana: () => ({ services: { - triggersActionsUi: { - actionTypeRegistry: { - get: jest.fn().mockReturnValue({ - actionTypeTitle: '.jira', - iconClass: 'logoSecurity', - }), - }, - }, + triggersActionsUi: mockTriggersActionsUiService, }, }), }; @@ -139,6 +148,16 @@ describe('AllCasesGeneric', () => { userCanCrud: true, }; + const { createMockActionTypeModel } = actionTypeRegistryMock; + + beforeAll(() => { + connectorsMock.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); + }); + beforeEach(() => { jest.clearAllMocks(); useUpdateCasesMock.mockReturnValue(defaultUpdateCases); @@ -146,6 +165,7 @@ describe('AllCasesGeneric', () => { useDeleteCasesMock.mockReturnValue(defaultDeleteCases); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); useGetActionLicenseMock.mockReturnValue(defaultActionLicense); + useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); moment.tz.setDefault('UTC'); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 141be31a093f1..8eae574776e2e 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -13,6 +13,7 @@ import { ConnectorsDropdown, Props } from './connectors_dropdown'; import { TestProviders } from '../../common/mock'; import { connectors } from './__mock__'; import { useKibana } from '../../common/lib/kibana'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; @@ -27,11 +28,14 @@ describe('ConnectorsDropdown', () => { selectedConnector: 'none', }; + const { createMockActionTypeModel } = actionTypeRegistryMock; + beforeAll(() => { - useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({ - actionTypeTitle: '.servicenow', - iconClass: 'logoSecurity', - }); + connectors.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); wrapper = mount(, { wrappingComponent: TestProviders }); }); @@ -219,4 +223,26 @@ describe('ConnectorsDropdown', () => { options.some((o) => o['data-test-subj'] === 'dropdown-connector-servicenow-sir') ).toBeFalsy(); }); + + test('it does not throw when accessing the icon if the connector type is not registered', () => { + expect(() => + mount( + , + { + wrappingComponent: TestProviders, + } + ) + ).not.toThrowError(); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index d26ec06d696fc..3cab2afd41f41 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -13,6 +13,7 @@ import { ConnectorTypes } from '../../../common'; import { ActionConnector } from '../../containers/configure/types'; import * as i18n from './translations'; import { useKibana } from '../../common/lib/kibana'; +import { getConnectorIcon } from '../utils'; export interface Props { connectors: ActionConnector[]; @@ -81,10 +82,7 @@ const ConnectorsDropdownComponent: React.FC = ({ diff --git a/x-pack/plugins/cases/public/components/connectors/card.test.tsx b/x-pack/plugins/cases/public/components/connectors/card.test.tsx new file mode 100644 index 0000000000000..b5d70a6781916 --- /dev/null +++ b/x-pack/plugins/cases/public/components/connectors/card.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { ConnectorTypes } from '../../../common'; +import { useKibana } from '../../common/lib/kibana'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { connectors } from '../configure_cases/__mock__'; +import { ConnectorCard } from './card'; + +jest.mock('../../common/lib/kibana'); +const useKibanaMock = useKibana as jest.Mocked; + +describe('ConnectorCard ', () => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + + beforeAll(() => { + connectors.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); + }); + + it('it does not throw when accessing the icon if the connector type is not registered', () => { + expect(() => + mount( + + ) + ).not.toThrowError(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/connectors/card.tsx b/x-pack/plugins/cases/public/components/connectors/card.tsx index b27c7207e7b8a..86cd90dafb376 100644 --- a/x-pack/plugins/cases/public/components/connectors/card.tsx +++ b/x-pack/plugins/cases/public/components/connectors/card.tsx @@ -11,6 +11,7 @@ import styled from 'styled-components'; import { ConnectorTypes } from '../../../common'; import { useKibana } from '../../common/lib/kibana'; +import { getConnectorIcon } from '../utils'; interface ConnectorCardProps { connectorType: ConnectorTypes; @@ -47,16 +48,13 @@ const ConnectorCardDisplay: React.FC = ({ ), [listItems] ); + const icon = useMemo( - () => ( - - ), + () => , // eslint-disable-next-line react-hooks/exhaustive-deps [connectorType] ); + return ( <> {isLoading && } diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index bc6d5c8717ece..1b480c3f5d78a 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -21,20 +21,18 @@ import { schema, FormProps } from './schema'; import { TestProviders } from '../../common/mock'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; +import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks'; +import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { useKibana } from '../../common/lib/kibana'; + +const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); jest.mock('../../common/lib/kibana', () => ({ useKibana: () => ({ services: { notifications: {}, http: {}, - triggersActionsUi: { - actionTypeRegistry: { - get: jest.fn().mockReturnValue({ - actionTypeTitle: 'test', - iconClass: 'logoSecurity', - }), - }, - }, + triggersActionsUi: mockTriggersActionsUiService, }, }), })); @@ -48,6 +46,7 @@ const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetChoicesMock = useGetChoices as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useKibanaMock = useKibana as jest.Mocked; const useGetIncidentTypesResponse = { isLoading: false, @@ -87,6 +86,16 @@ describe('Connector', () => { return

{children}
; }; + const { createMockActionTypeModel } = actionTypeRegistryMock; + + beforeAll(() => { + connectorsMock.forEach((connector) => + useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register( + createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' }) + ) + ); + }); + beforeEach(() => { jest.resetAllMocks(); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx index 609183aa5c4ef..744b14926b358 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx @@ -157,10 +157,11 @@ export const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: b export const getPushInfo = ( caseServices: CaseServices, - parsedValue: { connector_id: string; connector_name: string }, + // a JSON parse failure will result in null for parsedValue + parsedValue: { connector_id: string | null; connector_name: string } | null, index: number ) => - parsedValue != null + parsedValue != null && parsedValue.connector_id != null ? { firstPush: caseServices[parsedValue.connector_id]?.firstPushIndex === index, parsedConnectorId: parsedValue.connector_id, diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts new file mode 100644 index 0000000000000..496dc8d8c8066 --- /dev/null +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { actionTypeRegistryMock } from '../../../triggers_actions_ui/public/application/action_type_registry.mock'; +import { triggersActionsUiMock } from '../../../triggers_actions_ui/public/mocks'; +import { getConnectorIcon } from './utils'; + +describe('Utils', () => { + describe('getConnectorIcon', () => { + const { createMockActionTypeModel } = actionTypeRegistryMock; + const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); + mockTriggersActionsUiService.actionTypeRegistry.register( + createMockActionTypeModel({ id: '.test', iconClass: 'test' }) + ); + + it('it returns the correct icon class', () => { + expect(getConnectorIcon(mockTriggersActionsUiService, '.test')).toBe('test'); + }); + + it('it returns an empty string if the type is undefined', () => { + expect(getConnectorIcon(mockTriggersActionsUiService)).toBe(''); + }); + + it('it returns an empty string if the type is not registered', () => { + expect(getConnectorIcon(mockTriggersActionsUiService, '.not-registered')).toBe(''); + }); + + it('it returns an empty string if it throws', () => { + mockTriggersActionsUiService.actionTypeRegistry.get = () => { + throw new Error(); + }; + + expect(getConnectorIcon(mockTriggersActionsUiService, '.not-registered')).toBe(''); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 033529c27a2d4..5f7480cb84f7c 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -5,8 +5,10 @@ * 2.0. */ +import { IconType } from '@elastic/eui'; import { ConnectorTypes } from '../../common'; import { FieldConfig, ValidationConfig } from '../common/shared_imports'; +import { StartPlugins } from '../types'; import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator'; import { CaseActionConnector } from './types'; @@ -41,3 +43,28 @@ export const getConnectorsFormValidators = ({ }, ], }); + +export const getConnectorIcon = ( + triggersActionsUi: StartPlugins['triggersActionsUi'], + type?: string +): IconType => { + /** + * triggersActionsUi.actionTypeRegistry.get will throw an error if the type is not registered. + * This will break Kibana if not handled properly. + */ + const emptyResponse = ''; + + if (type == null) { + return emptyResponse; + } + + try { + if (triggersActionsUi.actionTypeRegistry.has(type)) { + return triggersActionsUi.actionTypeRegistry.get(type).iconClass; + } + } catch { + return emptyResponse; + } + + return emptyResponse; +}; diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 03ea76ede5c2e..887990fef8938 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -25,15 +25,9 @@ import { MAX_TITLE_LENGTH, } from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; -import { getConnectorFromConfiguration } from '../utils'; import { Operations } from '../../authorization'; -import { - createCaseError, - flattenCaseSavedObject, - transformCaseConnectorToEsConnector, - transformNewCase, -} from '../../common'; +import { createCaseError, flattenCaseSavedObject, transformNewCase } from '../../common'; import { CasesClientArgs } from '..'; /** @@ -48,7 +42,6 @@ export const create = async ( const { unsecuredSavedObjectsClient, caseService, - caseConfigureService, userActionService, user, logger, @@ -90,10 +83,6 @@ export const create = async ( // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = user; const createdDate = new Date().toISOString(); - const myCaseConfigure = await caseConfigureService.find({ - unsecuredSavedObjectsClient, - }); - const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); const newCase = await caseService.postNewCase({ unsecuredSavedObjectsClient, @@ -103,7 +92,7 @@ export const create = async ( username, full_name, email, - connector: transformCaseConnectorToEsConnector(query.connector ?? caseConfigureConnector), + connector: query.connector, }), id: savedObjectID, }); diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index 4f8713704361b..d440cecd27e9b 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -13,7 +13,6 @@ import { SavedObject } from 'kibana/server'; import { CaseResponseRt, CaseResponse, - ESCaseAttributes, User, UsersRt, AllTagsFindRequest, @@ -27,6 +26,7 @@ import { ENABLE_CASE_CONNECTOR, CasesByAlertId, CasesByAlertIdRt, + CaseAttributes, } from '../../../common'; import { countAlertsForID, createCaseError, flattenCaseSavedObject } from '../../common'; import { CasesClientArgs } from '..'; @@ -171,7 +171,7 @@ export const get = async ( ); } - let theCase: SavedObject; + let theCase: SavedObject; let subCaseIds: string[] = []; if (ENABLE_CASE_CONNECTOR) { const [caseInfo, subCasesForCaseId] = await Promise.all([ diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index 9e2066984a9da..3048cf01bb3ba 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -14,10 +14,10 @@ import { CaseResponse, CaseStatuses, ExternalServiceResponse, - ESCaseAttributes, - ESCasesConfigureAttributes, CaseType, ENABLE_CASE_CONNECTOR, + CasesConfigureAttributes, + CaseAttributes, } from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; @@ -33,8 +33,8 @@ import { casesConnectors } from '../../connectors'; * In the future we could allow push to close all the sub cases of a collection but that's not currently supported. */ function shouldCloseByPush( - configureSettings: SavedObjectsFindResponse, - caseInfo: SavedObject + configureSettings: SavedObjectsFindResponse, + caseInfo: SavedObject ): boolean { return ( configureSettings.total > 0 && @@ -186,6 +186,7 @@ export const push = async ( const [updatedCase, updatedComments] = await Promise.all([ caseService.patchCase({ + originalCase: myCase, unsecuredSavedObjectsClient, caseId, updatedAttributes: { diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index afe43171563ce..ed19444414d57 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -34,13 +34,12 @@ import { CommentAttributes, CommentType, ENABLE_CASE_CONNECTOR, - ESCaseAttributes, - ESCasePatchRequest, excess, MAX_CONCURRENT_SEARCHES, SUB_CASE_SAVED_OBJECT, throwErrors, MAX_TITLE_LENGTH, + CaseAttributes, } from '../../../common'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { getCaseToUpdate } from '../utils'; @@ -51,7 +50,6 @@ import { createCaseError, flattenCaseSavedObject, isCommentRequestTypeAlertOrGenAlert, - transformCaseConnectorToEsConnector, } from '../../common'; import { UpdateAlertRequest } from '../alerts/types'; import { CasesClientInternal } from '../client_internal'; @@ -61,17 +59,14 @@ import { Operations, OwnerEntity } from '../../authorization'; /** * Throws an error if any of the requests attempt to update a collection style cases' status field. */ -function throwIfUpdateStatusOfCollection( - requests: ESCasePatchRequest[], - casesMap: Map> -) { +function throwIfUpdateStatusOfCollection(requests: UpdateRequestWithOriginalCase[]) { const requestsUpdatingStatusOfCollection = requests.filter( - (req) => - req.status !== undefined && casesMap.get(req.id)?.attributes.type === CaseType.collection + ({ updateReq, originalCase }) => + updateReq.status !== undefined && originalCase.attributes.type === CaseType.collection ); if (requestsUpdatingStatusOfCollection.length > 0) { - const ids = requestsUpdatingStatusOfCollection.map((req) => req.id); + const ids = requestsUpdatingStatusOfCollection.map(({ updateReq }) => updateReq.id); throw Boom.badRequest( `Updating the status of a collection is not allowed ids: [${ids.join(', ')}]` ); @@ -81,18 +76,14 @@ function throwIfUpdateStatusOfCollection( /** * Throws an error if any of the requests attempt to update a collection style case to an individual one. */ -function throwIfUpdateTypeCollectionToIndividual( - requests: ESCasePatchRequest[], - casesMap: Map> -) { +function throwIfUpdateTypeCollectionToIndividual(requests: UpdateRequestWithOriginalCase[]) { const requestsUpdatingTypeCollectionToInd = requests.filter( - (req) => - req.type === CaseType.individual && - casesMap.get(req.id)?.attributes.type === CaseType.collection + ({ updateReq, originalCase }) => + updateReq.type === CaseType.individual && originalCase.attributes.type === CaseType.collection ); if (requestsUpdatingTypeCollectionToInd.length > 0) { - const ids = requestsUpdatingTypeCollectionToInd.map((req) => req.id); + const ids = requestsUpdatingTypeCollectionToInd.map(({ updateReq }) => updateReq.id); throw Boom.badRequest( `Converting a collection to an individual case is not allowed ids: [${ids.join(', ')}]` ); @@ -102,11 +93,11 @@ function throwIfUpdateTypeCollectionToIndividual( /** * Throws an error if any of the requests attempt to update the type of a case. */ -function throwIfUpdateType(requests: ESCasePatchRequest[]) { - const requestsUpdatingType = requests.filter((req) => req.type !== undefined); +function throwIfUpdateType(requests: UpdateRequestWithOriginalCase[]) { + const requestsUpdatingType = requests.filter(({ updateReq }) => updateReq.type !== undefined); if (requestsUpdatingType.length > 0) { - const ids = requestsUpdatingType.map((req) => req.id); + const ids = requestsUpdatingType.map(({ updateReq }) => updateReq.id); throw Boom.badRequest( `Updating the type of a case when sub cases are disabled is not allowed ids: [${ids.join( ', ' @@ -118,11 +109,11 @@ function throwIfUpdateType(requests: ESCasePatchRequest[]) { /** * Throws an error if any of the requests attempt to update the owner of a case. */ -function throwIfUpdateOwner(requests: ESCasePatchRequest[]) { - const requestsUpdatingOwner = requests.filter((req) => req.owner !== undefined); +function throwIfUpdateOwner(requests: UpdateRequestWithOriginalCase[]) { + const requestsUpdatingOwner = requests.filter(({ updateReq }) => updateReq.owner !== undefined); if (requestsUpdatingOwner.length > 0) { - const ids = requestsUpdatingOwner.map((req) => req.id); + const ids = requestsUpdatingOwner.map(({ updateReq }) => updateReq.id); throw Boom.badRequest(`Updating the owner of a case is not allowed ids: [${ids.join(', ')}]`); } } @@ -136,14 +127,14 @@ async function throwIfInvalidUpdateOfTypeWithAlerts({ caseService, unsecuredSavedObjectsClient, }: { - requests: ESCasePatchRequest[]; + requests: UpdateRequestWithOriginalCase[]; caseService: CasesService; unsecuredSavedObjectsClient: SavedObjectsClientContract; }) { - const getAlertsForID = async (caseToUpdate: ESCasePatchRequest) => { + const getAlertsForID = async ({ updateReq }: UpdateRequestWithOriginalCase) => { const alerts = await caseService.getAllCaseComments({ unsecuredSavedObjectsClient, - id: caseToUpdate.id, + id: updateReq.id, options: { fields: [], // there should never be generated alerts attached to an individual case but we'll check anyway @@ -159,11 +150,14 @@ async function throwIfInvalidUpdateOfTypeWithAlerts({ }, }); - return { id: caseToUpdate.id, alerts }; + return { id: updateReq.id, alerts }; }; - const requestsUpdatingTypeField = requests.filter((req) => req.type === CaseType.collection); - const getAlertsMapper = async (caseToUpdate: ESCasePatchRequest) => getAlertsForID(caseToUpdate); + const requestsUpdatingTypeField = requests.filter( + ({ updateReq }) => updateReq.type === CaseType.collection + ); + const getAlertsMapper = async (caseToUpdate: UpdateRequestWithOriginalCase) => + getAlertsForID(caseToUpdate); // Ensuring we don't too many concurrent get running. const casesAlertTotals = await pMap(requestsUpdatingTypeField, getAlertsMapper, { concurrency: MAX_CONCURRENT_SEARCHES, @@ -185,13 +179,13 @@ async function throwIfInvalidUpdateOfTypeWithAlerts({ /** * Throws an error if any of the requests updates a title and the length is over MAX_TITLE_LENGTH. */ -function throwIfTitleIsInvalid(requests: ESCasePatchRequest[]) { +function throwIfTitleIsInvalid(requests: UpdateRequestWithOriginalCase[]) { const requestsInvalidTitle = requests.filter( - (req) => req.title !== undefined && req.title.length > MAX_TITLE_LENGTH + ({ updateReq }) => updateReq.title !== undefined && updateReq.title.length > MAX_TITLE_LENGTH ); if (requestsInvalidTitle.length > 0) { - const ids = requestsInvalidTitle.map((req) => req.id); + const ids = requestsInvalidTitle.map(({ updateReq }) => updateReq.id); throw Boom.badRequest( `The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}, ids: [${ids.join( ', ' @@ -218,11 +212,11 @@ async function getAlertComments({ caseService, unsecuredSavedObjectsClient, }: { - casesToSync: ESCasePatchRequest[]; + casesToSync: UpdateRequestWithOriginalCase[]; caseService: CasesService; unsecuredSavedObjectsClient: SavedObjectsClientContract; }): Promise> { - const idsOfCasesToSync = casesToSync.map((casePatchReq) => casePatchReq.id); + const idsOfCasesToSync = casesToSync.map(({ updateReq }) => updateReq.id); // getAllCaseComments will by default get all the comments, unless page or perPage fields are set return caseService.getAllCaseComments({ @@ -310,14 +304,12 @@ function getSyncStatusForComment({ async function updateAlerts({ casesWithSyncSettingChangedToOn, casesWithStatusChangedAndSynced, - casesMap, caseService, unsecuredSavedObjectsClient, casesClientInternal, }: { - casesWithSyncSettingChangedToOn: ESCasePatchRequest[]; - casesWithStatusChangedAndSynced: ESCasePatchRequest[]; - casesMap: Map>; + casesWithSyncSettingChangedToOn: UpdateRequestWithOriginalCase[]; + casesWithStatusChangedAndSynced: UpdateRequestWithOriginalCase[]; caseService: CasesService; unsecuredSavedObjectsClient: SavedObjectsClientContract; casesClientInternal: CasesClientInternal; @@ -331,11 +323,8 @@ async function updateAlerts({ // build a map of case id to the status it has // this will have collections in it but the alerts should be associated to sub cases and not collections so it shouldn't // matter. - const casesToSyncToStatus = casesToSync.reduce((acc, caseInfo) => { - acc.set( - caseInfo.id, - caseInfo.status ?? casesMap.get(caseInfo.id)?.attributes.status ?? CaseStatuses.open - ); + const casesToSyncToStatus = casesToSync.reduce((acc, { updateReq, originalCase }) => { + acc.set(updateReq.id, updateReq.status ?? originalCase.attributes.status ?? CaseStatuses.open); return acc; }, new Map()); @@ -376,7 +365,7 @@ async function updateAlerts({ } function partitionPatchRequest( - casesMap: Map>, + casesMap: Map>, patchReqCases: CasePatchRequest[] ): { nonExistingCases: CasePatchRequest[]; @@ -409,6 +398,11 @@ function partitionPatchRequest( }; } +interface UpdateRequestWithOriginalCase { + updateReq: CasePatchRequest; + originalCase: SavedObject; +} + /** * Updates the specified cases with new values * @@ -441,7 +435,7 @@ export const update = async ( const casesMap = myCases.saved_objects.reduce((acc, so) => { acc.set(so.id, so); return acc; - }, new Map>()); + }, new Map>()); const { nonExistingCases, conflictedCases, casesToAuthorize } = partitionPatchRequest( casesMap, @@ -469,38 +463,41 @@ export const update = async ( ); } - const updateCases: ESCasePatchRequest[] = query.cases.map((updateCase) => { - const currentCase = myCases.saved_objects.find((c) => c.id === updateCase.id); - const { connector, ...thisCase } = updateCase; - return currentCase != null - ? getCaseToUpdate(currentCase.attributes, { - ...thisCase, - ...(connector != null - ? { connector: transformCaseConnectorToEsConnector(connector) } - : {}), - }) - : { id: thisCase.id, version: thisCase.version }; - }); + const updateCases: UpdateRequestWithOriginalCase[] = query.cases.reduce( + (acc: UpdateRequestWithOriginalCase[], updateCase) => { + const originalCase = casesMap.get(updateCase.id); - const updateFilterCases = updateCases.filter((updateCase) => { - const { id, version, ...updateCaseAttributes } = updateCase; - return Object.keys(updateCaseAttributes).length > 0; - }); + if (!originalCase) { + return acc; + } + + const fieldsToUpdate = getCaseToUpdate(originalCase.attributes, updateCase); - if (updateFilterCases.length <= 0) { + const { id, version, ...restFields } = fieldsToUpdate; + + if (Object.keys(restFields).length > 0) { + acc.push({ originalCase, updateReq: fieldsToUpdate }); + } + + return acc; + }, + [] + ); + + if (updateCases.length <= 0) { throw Boom.notAcceptable('All update fields are identical to current version.'); } if (!ENABLE_CASE_CONNECTOR) { - throwIfUpdateType(updateFilterCases); + throwIfUpdateType(updateCases); } - throwIfUpdateOwner(updateFilterCases); - throwIfTitleIsInvalid(updateFilterCases); - throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); - throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); + throwIfUpdateOwner(updateCases); + throwIfTitleIsInvalid(updateCases); + throwIfUpdateStatusOfCollection(updateCases); + throwIfUpdateTypeCollectionToIndividual(updateCases); await throwIfInvalidUpdateOfTypeWithAlerts({ - requests: updateFilterCases, + requests: updateCases, caseService, unsecuredSavedObjectsClient, }); @@ -510,9 +507,9 @@ export const update = async ( const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ unsecuredSavedObjectsClient, - cases: updateFilterCases.map((thisCase) => { + cases: updateCases.map(({ updateReq, originalCase }) => { // intentionally removing owner from the case so that we don't accidentally allow it to be updated - const { id: caseId, version, owner, ...updateCaseAttributes } = thisCase; + const { id: caseId, version, owner, ...updateCaseAttributes } = updateReq; let closedInfo = {}; if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { closedInfo = { @@ -531,6 +528,7 @@ export const update = async ( } return { caseId, + originalCase, updatedAttributes: { ...updateCaseAttributes, ...closedInfo, @@ -544,25 +542,23 @@ export const update = async ( // If a status update occurred and the case is synced then we need to update all alerts' status // attached to the case to the new status. - const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + const casesWithStatusChangedAndSynced = updateCases.filter(({ updateReq, originalCase }) => { return ( - currentCase != null && - caseToUpdate.status != null && - currentCase.attributes.status !== caseToUpdate.status && - currentCase.attributes.settings.syncAlerts + originalCase != null && + updateReq.status != null && + originalCase.attributes.status !== updateReq.status && + originalCase.attributes.settings.syncAlerts ); }); // If syncAlerts setting turned on we need to update all alerts' status // attached to the case to the current status. - const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + const casesWithSyncSettingChangedToOn = updateCases.filter(({ updateReq, originalCase }) => { return ( - currentCase != null && - caseToUpdate.settings?.syncAlerts != null && - currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && - caseToUpdate.settings.syncAlerts + originalCase != null && + updateReq.settings?.syncAlerts != null && + originalCase.attributes.settings.syncAlerts !== updateReq.settings.syncAlerts && + updateReq.settings.syncAlerts ); }); @@ -573,7 +569,6 @@ export const update = async ( caseService, unsecuredSavedObjectsClient, casesClientInternal, - casesMap, }); const returnUpdatedCase = myCases.saved_objects diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index e8ff984fef994..ad7e1322c9e06 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -20,13 +20,13 @@ import { CaseConfigurationsResponseRt, CaseConfigureResponseRt, CasesConfigurationsResponse, + CasesConfigureAttributes, CasesConfigurePatch, CasesConfigurePatchRt, CasesConfigureRequest, CasesConfigureResponse, ConnectorMappings, ConnectorMappingsAttributes, - ESCasesConfigureAttributes, excess, GetConfigureFindRequest, GetConfigureFindRequestRt, @@ -34,11 +34,7 @@ import { SUPPORTED_CONNECTORS, throwErrors, } from '../../../common'; -import { - createCaseError, - transformCaseConnectorToEsConnector, - transformESConnectorToCaseConnector, -} from '../../common'; +import { createCaseError } from '../../common'; import { CasesClientInternal } from '../client_internal'; import { CasesClientArgs } from '../types'; import { getMappings } from './get_mappings'; @@ -174,7 +170,7 @@ async function get( const configurations = await pMap( myCaseConfigure.saved_objects, - async (configuration: SavedObject) => { + async (configuration: SavedObject) => { const { connector, ...caseConfigureWithoutConnector } = configuration?.attributes ?? { connector: null, }; @@ -184,7 +180,7 @@ async function get( if (connector != null) { try { mappings = await casesClientInternal.configuration.getMappings({ - connector: transformESConnectorToCaseConnector(connector), + connector, }); } catch (e) { error = e.isBoom @@ -195,7 +191,7 @@ async function get( return { ...caseConfigureWithoutConnector, - connector: transformESConnectorToCaseConnector(connector), + connector, mappings: mappings.length > 0 ? mappings[0].attributes.mappings : [], version: configuration.version ?? '', error, @@ -292,11 +288,9 @@ async function update( try { const resMappings = await casesClientInternal.configuration.getMappings({ - connector: - connector != null - ? connector - : transformESConnectorToCaseConnector(configuration.attributes.connector), + connector: connector != null ? connector : configuration.attributes.connector, }); + mappings = resMappings.length > 0 ? resMappings[0].attributes.mappings : []; if (connector != null) { @@ -325,18 +319,17 @@ async function update( configurationId: configuration.id, updatedAttributes: { ...queryWithoutVersionAndConnector, - ...(connector != null ? { connector: transformCaseConnectorToEsConnector(connector) } : {}), + ...(connector != null && { connector }), updated_at: updateDate, updated_by: user, }, + originalConfiguration: configuration, }); return CaseConfigureResponseRt.encode({ ...configuration.attributes, ...patch.attributes, - connector: transformESConnectorToCaseConnector( - patch.attributes.connector ?? configuration.attributes.connector - ), + connector: patch.attributes.connector ?? configuration.attributes.connector, mappings, version: patch.version ?? '', error, @@ -397,7 +390,7 @@ async function create( ); if (myCaseConfigure.saved_objects.length > 0) { - const deleteConfigurationMapper = async (c: SavedObject) => + const deleteConfigurationMapper = async (c: SavedObject) => caseConfigureService.delete({ unsecuredSavedObjectsClient, configurationId: c.id }); // Ensuring we don't too many concurrent deletions running. @@ -431,7 +424,7 @@ async function create( unsecuredSavedObjectsClient, attributes: { ...configuration, - connector: transformCaseConnectorToEsConnector(configuration.connector), + connector: configuration.connector, created_at: creationDate, created_by: user, updated_at: null, @@ -443,7 +436,7 @@ async function create( return CaseConfigureResponseRt.encode({ ...post.attributes, // Reserve for future implementations - connector: transformESConnectorToCaseConnector(post.attributes.connector), + connector: post.attributes.connector, mappings, version: post.version ?? '', error, diff --git a/x-pack/plugins/cases/server/client/sub_cases/update.ts b/x-pack/plugins/cases/server/client/sub_cases/update.ts index be671a8087f8e..c8cb96cbb6b8c 100644 --- a/x-pack/plugins/cases/server/client/sub_cases/update.ts +++ b/x-pack/plugins/cases/server/client/sub_cases/update.ts @@ -23,7 +23,6 @@ import { CaseStatuses, CommentAttributes, CommentType, - ESCaseAttributes, excess, SUB_CASE_SAVED_OBJECT, SubCaseAttributes, @@ -35,6 +34,7 @@ import { SubCasesResponseRt, throwErrors, User, + CaseAttributes, } from '../../../common'; import { getCaseToUpdate } from '../utils'; import { buildSubCaseUserActions } from '../../services/user_actions/helpers'; @@ -124,7 +124,7 @@ async function getParentCases({ unsecuredSavedObjectsClient: SavedObjectsClientContract; subCaseIDs: string[]; subCasesMap: Map>; -}): Promise>> { +}): Promise>> { const parentIDInfo = getParentIDs({ subCaseIDs, subCasesMap }); const parentCases = await caseService.getCases({ @@ -148,7 +148,7 @@ async function getParentCases({ acc.set(subCaseId, so); }); return acc; - }, new Map>()); + }, new Map>()); } function getValidUpdateRequests( diff --git a/x-pack/plugins/cases/server/client/utils.test.ts b/x-pack/plugins/cases/server/client/utils.test.ts index c8ed1f4f0efa6..45ea6bacb0f51 100644 --- a/x-pack/plugins/cases/server/client/utils.test.ts +++ b/x-pack/plugins/cases/server/client/utils.test.ts @@ -5,113 +5,12 @@ * 2.0. */ -import { SavedObjectsFindResponse } from 'kibana/server'; -import { - CaseConnector, - CaseType, - ConnectorTypes, - ESCaseConnector, - ESCasesConfigureAttributes, -} from '../../common/api'; -import { mockCaseConfigure } from '../routes/api/__fixtures__'; +import { CaseConnector, CaseType, ConnectorTypes } from '../../common/api'; import { newCase } from '../routes/api/__mocks__/request_responses'; -import { - transformCaseConnectorToEsConnector, - transformESConnectorToCaseConnector, - transformNewCase, -} from '../common'; -import { getConnectorFromConfiguration, sortToSnake } from './utils'; +import { transformNewCase } from '../common'; +import { sortToSnake } from './utils'; describe('utils', () => { - const caseConnector: CaseConnector = { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }; - - const esCaseConnector: ESCaseConnector = { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], - }; - - const caseConfigure: SavedObjectsFindResponse = { - saved_objects: [{ ...mockCaseConfigure[0], score: 0 }], - total: 1, - per_page: 20, - page: 1, - }; - - describe('transformCaseConnectorToEsConnector', () => { - it('transform correctly', () => { - expect(transformCaseConnectorToEsConnector(caseConnector)).toEqual(esCaseConnector); - }); - - it('transform correctly with null attributes', () => { - // @ts-ignore this is case the connector does not exist for old cases object or configurations - expect(transformCaseConnectorToEsConnector(null)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: [], - }); - }); - }); - - describe('transformESConnectorToCaseConnector', () => { - it('transform correctly', () => { - expect(transformESConnectorToCaseConnector(esCaseConnector)).toEqual(caseConnector); - }); - - it('transform correctly with null attributes', () => { - // @ts-ignore this is case the connector does not exist for old cases object or configurations - expect(transformESConnectorToCaseConnector(null)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }); - }); - }); - - describe('getConnectorFromConfiguration', () => { - it('transform correctly', () => { - expect(getConnectorFromConfiguration(caseConfigure)).toEqual({ - id: '789', - name: 'My connector 3', - type: ConnectorTypes.jira, - fields: null, - }); - }); - - it('transform correctly with no connector', () => { - const caseConfigureNoConnector: SavedObjectsFindResponse = { - ...caseConfigure, - saved_objects: [ - { - ...mockCaseConfigure[0], - // @ts-ignore this is case the connector does not exist for old cases object or configurations - attributes: { ...mockCaseConfigure[0].attributes, connector: null }, - score: 0, - }, - ], - }; - - expect(getConnectorFromConfiguration(caseConfigureNoConnector)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }); - }); - }); - describe('sortToSnake', () => { it('it transforms status correctly', () => { expect(sortToSnake('status')).toBe('status'); @@ -139,15 +38,11 @@ describe('utils', () => { }); describe('transformNewCase', () => { - const connector: ESCaseConnector = { + const connector: CaseConnector = { id: '123', name: 'My connector', type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], + fields: { issueType: 'Task', priority: 'High', parent: null }, }; it('transform correctly', () => { const myCase = { @@ -166,20 +61,11 @@ describe('utils', () => { "closed_at": null, "closed_by": null, "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, "id": "123", "name": "My connector", "type": ".jira", @@ -223,20 +109,11 @@ describe('utils', () => { "closed_at": null, "closed_by": null, "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, "id": "123", "name": "My connector", "type": ".jira", @@ -283,20 +160,11 @@ describe('utils', () => { "closed_at": null, "closed_by": null, "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, "id": "123", "name": "My connector", "type": ".jira", diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index 0e7a21816de4c..a6fd9984bfea6 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -12,20 +12,16 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; -import { SavedObjectsFindResponse } from 'kibana/server'; import { nodeBuilder, KueryNode } from '../../../../../src/plugins/data/common'; import { esKuery } from '../../../../../src/plugins/data/server'; import { AlertCommentRequestRt, ActionsCommentRequestRt, CASE_SAVED_OBJECT, - CaseConnector, CaseStatuses, CaseType, CommentRequest, - ConnectorTypes, ContextTypeUserRt, - ESCasesConfigureAttributes, excess, OWNER_FIELD, SUB_CASE_SAVED_OBJECT, @@ -437,31 +433,6 @@ export const getCaseToUpdate = ( { id: queryCase.id, version: queryCase.version } ); -export const getNoneCaseConnector = () => ({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, -}); - -export const getConnectorFromConfiguration = ( - caseConfigure: SavedObjectsFindResponse -): CaseConnector => { - let caseConnector = getNoneCaseConnector(); - if ( - caseConfigure.saved_objects.length > 0 && - caseConfigure.saved_objects[0].attributes.connector - ) { - caseConnector = { - id: caseConfigure.saved_objects[0].attributes.connector.id, - name: caseConfigure.saved_objects[0].attributes.connector.name, - type: caseConfigure.saved_objects[0].attributes.connector.type, - fields: null, - }; - } - return caseConnector; -}; - enum SortFieldCase { closedAt = 'closed_at', createdAt = 'created_at', diff --git a/x-pack/plugins/cases/server/common/constants.ts b/x-pack/plugins/cases/server/common/constants.ts new file mode 100644 index 0000000000000..1f6af310d6ece --- /dev/null +++ b/x-pack/plugins/cases/server/common/constants.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * The name of the saved object reference indicating the action connector ID. This is stored in the Saved Object reference + * field's name property. + */ +export const CONNECTOR_ID_REFERENCE_NAME = 'connectorId'; + +/** + * The name of the saved object reference indicating the action connector ID that was used to push a case. + */ +export const PUSH_CONNECTOR_ID_REFERENCE_NAME = 'pushConnectorId'; diff --git a/x-pack/plugins/cases/server/common/index.ts b/x-pack/plugins/cases/server/common/index.ts index 324c7e7ffd1a8..ae9af177c1bb4 100644 --- a/x-pack/plugins/cases/server/common/index.ts +++ b/x-pack/plugins/cases/server/common/index.ts @@ -9,3 +9,4 @@ export * from './models'; export * from './utils'; export * from './types'; export * from './error'; +export * from './constants'; diff --git a/x-pack/plugins/cases/server/common/models/commentable_case.ts b/x-pack/plugins/cases/server/common/models/commentable_case.ts index e082a0b290f16..03d6e5b8cea63 100644 --- a/x-pack/plugins/cases/server/common/models/commentable_case.ts +++ b/x-pack/plugins/cases/server/common/models/commentable_case.ts @@ -25,18 +25,13 @@ import { CommentPatchRequest, CommentRequest, CommentType, - ESCaseAttributes, MAX_DOCS_PER_PAGE, SUB_CASE_SAVED_OBJECT, SubCaseAttributes, User, + CaseAttributes, } from '../../../common'; -import { - transformESConnectorToCaseConnector, - flattenCommentSavedObjects, - flattenSubCaseSavedObject, - transformNewComment, -} from '..'; +import { flattenCommentSavedObjects, flattenSubCaseSavedObject, transformNewComment } from '..'; import { AttachmentService, CasesService } from '../../services'; import { createCaseError } from '../error'; import { countAlertsForID } from '../index'; @@ -52,7 +47,7 @@ interface NewCommentResp { } interface CommentableCaseParams { - collection: SavedObject; + collection: SavedObject; subCase?: SavedObject; unsecuredSavedObjectsClient: SavedObjectsClientContract; caseService: CasesService; @@ -65,7 +60,7 @@ interface CommentableCaseParams { * a Sub Case, Case, and Collection. */ export class CommentableCase { - private readonly collection: SavedObject; + private readonly collection: SavedObject; private readonly subCase?: SavedObject; private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; private readonly caseService: CasesService; @@ -168,6 +163,7 @@ export class CommentableCase { } const updatedCase = await this.caseService.patchCase({ + originalCase: this.collection, unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, caseId: this.collection.id, updatedAttributes: { @@ -305,7 +301,6 @@ export class CommentableCase { version: this.collection.version ?? '0', totalComment, ...this.collection.attributes, - connector: transformESConnectorToCaseConnector(this.collection.attributes.connector), }; } diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 322e45094eda4..46ba33a74acd6 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -14,11 +14,7 @@ import { CommentRequest, CommentType, } from '../../common/api'; -import { - mockCaseComments, - mockCases, - mockCaseNoConnectorId, -} from '../routes/api/__fixtures__/mock_saved_objects'; +import { mockCaseComments, mockCases } from '../routes/api/__fixtures__/mock_saved_objects'; import { flattenCaseSavedObject, transformNewComment, @@ -470,14 +466,13 @@ describe('common utils', () => { `); }); - it('inserts missing connector', () => { + it('leaves the connector.id in the attributes', () => { const extraCaseData = { totalComment: 2, }; const res = flattenCaseSavedObject({ - // @ts-ignore this is to update old case saved objects to include connector - savedObject: mockCaseNoConnectorId, + savedObject: mockCases[0], ...extraCaseData, }); @@ -500,7 +495,8 @@ describe('common utils', () => { }, "description": "This is a brand new case of a bad meanie defacing data", "external_service": null, - "id": "mock-no-connector_id", + "id": "mock-id-1", + "owner": "securitySolution", "settings": Object { "syncAlerts": true, }, @@ -513,6 +509,7 @@ describe('common utils', () => { "title": "Super Bad Security Issue", "totalAlerts": 0, "totalComment": 2, + "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", "updated_by": Object { "email": "testemail@elastic.co", diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index 13d3f3768f391..bce37764467df 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -12,6 +12,7 @@ import { AlertInfo } from '.'; import { AssociationType, + CaseAttributes, CaseConnector, CaseResponse, CasesClientPostRequest, @@ -24,11 +25,8 @@ import { CommentResponse, CommentsResponse, CommentType, - ConnectorTypeFields, + ConnectorTypes, ENABLE_CASE_CONNECTOR, - ESCaseAttributes, - ESCaseConnector, - ESConnectorFields, SubCaseAttributes, SubCaseResponse, SubCasesFindResponse, @@ -55,13 +53,13 @@ export const transformNewCase = ({ newCase, username, }: { - connector: ESCaseConnector; + connector: CaseConnector; createdDate: string; email?: string | null; full_name?: string | null; newCase: CasesClientPostRequest; username?: string | null; -}): ESCaseAttributes => ({ +}): CaseAttributes => ({ ...newCase, closed_at: null, closed_by: null, @@ -135,7 +133,7 @@ export const flattenCaseSavedObject = ({ subCases, subCaseIds, }: { - savedObject: SavedObject; + savedObject: SavedObject; comments?: Array>; totalComment?: number; totalAlerts?: number; @@ -148,7 +146,6 @@ export const flattenCaseSavedObject = ({ totalComment, totalAlerts, ...savedObject.attributes, - connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), subCases, subCaseIds: !isEmpty(subCaseIds) ? subCaseIds : undefined, }); @@ -196,47 +193,6 @@ export const flattenCommentSavedObject = ( ...savedObject.attributes, }); -export const transformCaseConnectorToEsConnector = (connector: CaseConnector): ESCaseConnector => ({ - id: connector?.id ?? 'none', - name: connector?.name ?? 'none', - type: connector?.type ?? '.none', - fields: - connector?.fields != null - ? Object.entries(connector.fields).reduce( - (acc, [key, value]) => [ - ...acc, - { - key, - value, - }, - ], - [] - ) - : [], -}); - -export const transformESConnectorToCaseConnector = (connector?: ESCaseConnector): CaseConnector => { - const connectorTypeField = { - type: connector?.type ?? '.none', - fields: - connector && connector.fields != null && connector.fields.length > 0 - ? connector.fields.reduce( - (fields, { key, value }) => ({ - ...fields, - [key]: value, - }), - {} - ) - : null, - } as ConnectorTypeFields; - - return { - id: connector?.id ?? 'none', - name: connector?.name ?? 'none', - ...connectorTypeField, - }; -}; - export const getIDsAndIndicesAsArrays = ( comment: CommentRequestAlertType ): { ids: string[]; indices: string[] } => { @@ -430,3 +386,15 @@ export function checkEnabledCaseConnectorOrThrow(subCaseID: string | undefined) ); } } + +/** + * Returns a connector that indicates that no connector was set. + * + * @returns the 'none' connector + */ +export const getNoneCaseConnector = () => ({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, +}); diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts index 625324312e6b9..1551f0fa611b7 100644 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -8,17 +8,16 @@ import { SavedObject } from 'kibana/server'; import { AssociationType, + CaseAttributes, CaseStatuses, CaseType, CommentAttributes, CommentType, ConnectorTypes, - ESCaseAttributes, - ESCasesConfigureAttributes, SECURITY_SOLUTION_OWNER, } from '../../../../common'; -export const mockCases: Array> = [ +export const mockCases: Array> = [ { type: 'cases', id: 'mock-id-1', @@ -29,7 +28,7 @@ export const mockCases: Array> = [ id: 'none', name: 'none', type: ConnectorTypes.none, - fields: [], + fields: null, }, created_at: '2019-11-25T21:54:48.952Z', created_by: { @@ -68,7 +67,7 @@ export const mockCases: Array> = [ id: 'none', name: 'none', type: ConnectorTypes.none, - fields: [], + fields: null, }, created_at: '2019-11-25T22:32:00.900Z', created_by: { @@ -107,11 +106,7 @@ export const mockCases: Array> = [ id: '123', name: 'My connector', type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], + fields: { issueType: 'Task', priority: 'High', parent: null }, }, created_at: '2019-11-25T22:32:17.947Z', created_by: { @@ -154,11 +149,7 @@ export const mockCases: Array> = [ id: '123', name: 'My connector', type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], + fields: { issueType: 'Task', priority: 'High', parent: null }, }, created_at: '2019-11-25T22:32:17.947Z', created_by: { @@ -189,38 +180,6 @@ export const mockCases: Array> = [ }, ]; -export const mockCaseNoConnectorId: SavedObject> = { - type: 'cases', - id: 'mock-no-connector_id', - attributes: { - closed_at: null, - closed_by: null, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - settings: { - syncAlerts: true, - }, - }, - references: [], - updated_at: '2019-11-25T21:54:48.952Z', - version: 'WzAsMV0=', -}; - export const mockCasesErrorTriggerData = [ { id: 'valid-id', @@ -446,35 +405,3 @@ export const mockCaseComments: Array> = [ version: 'WzYsMV0=', }, ]; - -export const mockCaseConfigure: Array> = [ - { - type: 'cases-configure', - id: 'mock-configuration-1', - attributes: { - connector: { - id: '789', - name: 'My connector 3', - type: ConnectorTypes.jira, - fields: null, - }, - closure_type: 'close-by-user', - created_at: '2020-04-09T09:43:51.778Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - updated_at: '2020-04-09T09:43:51.778Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - owner: SECURITY_SOLUTION_OWNER, - }, - references: [], - updated_at: '2020-04-09T09:43:51.778Z', - version: 'WzYsMV0=', - }, -]; diff --git a/x-pack/plugins/cases/server/saved_object_types/cases.ts b/x-pack/plugins/cases/server/saved_object_types/cases.ts index 869437f125ca0..199017c36fa3e 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases.ts @@ -52,9 +52,6 @@ export const caseSavedObjectType: SavedObjectsType = { }, connector: { properties: { - id: { - type: 'keyword', - }, name: { type: 'text', }, @@ -91,9 +88,6 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, - connector_id: { - type: 'keyword', - }, connector_name: { type: 'keyword', }, diff --git a/x-pack/plugins/cases/server/saved_object_types/configure.ts b/x-pack/plugins/cases/server/saved_object_types/configure.ts index e88ecb93d9d65..a763a8243cc2d 100644 --- a/x-pack/plugins/cases/server/saved_object_types/configure.ts +++ b/x-pack/plugins/cases/server/saved_object_types/configure.ts @@ -33,9 +33,6 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, connector: { properties: { - id: { - type: 'keyword', - }, name: { type: 'text', }, diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts new file mode 100644 index 0000000000000..bca12a86a544e --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts @@ -0,0 +1,351 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectSanitizedDoc } from 'kibana/server'; +import { + CaseAttributes, + CaseFullExternalService, + CASE_SAVED_OBJECT, + ConnectorTypes, + noneConnectorId, +} from '../../../common'; +import { getNoneCaseConnector } from '../../common'; +import { createExternalService, ESCaseConnectorWithId } from '../../services/test_utils'; +import { caseConnectorIdMigration } from './cases'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +const create_7_14_0_case = ({ + connector, + externalService, +}: { connector?: ESCaseConnectorWithId; externalService?: CaseFullExternalService } = {}) => ({ + type: CASE_SAVED_OBJECT, + id: '1', + attributes: { + connector, + external_service: externalService, + }, +}); + +describe('7.15.0 connector ID migration', () => { + it('does not create a reference when the connector.id is none', () => { + const caseSavedObject = create_7_14_0_case({ connector: getNoneCaseConnector() }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); + + it('does not create a reference when the connector is undefined', () => { + const caseSavedObject = create_7_14_0_case(); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); + + it('sets the connector to the default none connector if the connector.id is undefined', () => { + const caseSavedObject = create_7_14_0_case({ + connector: { + fields: null, + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + } as ESCaseConnectorWithId, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); + + it('does not create a reference when the external_service is null', () => { + const caseSavedObject = create_7_14_0_case({ externalService: null }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).toBeNull(); + }); + + it('does not create a reference when the external_service is undefined and sets external_service to null', () => { + const caseSavedObject = create_7_14_0_case(); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).toBeNull(); + }); + + it('does not create a reference when the external_service.connector_id is none', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: createExternalService({ connector_id: noneConnectorId }), + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('preserves the existing references when migrating', () => { + const caseSavedObject = { + ...create_7_14_0_case(), + references: [{ id: '1', name: 'awesome', type: 'hello' }], + }; + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(1); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "awesome", + "type": "hello", + }, + ] + `); + }); + + it('creates a connector reference and removes the connector.id field', () => { + const caseSavedObject = create_7_14_0_case({ + connector: { + id: '123', + fields: null, + name: 'connector', + type: ConnectorTypes.jira, + }, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(1); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "connector", + "type": ".jira", + } + `); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "123", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('creates a push connector reference and removes the connector_id field', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: { + connector_id: '100', + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(1); + expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('does not create a reference and preserves the existing external_service fields when connector_id is null', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: { + connector_id: null, + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('migrates both connector and external_service when provided', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: { + connector_id: '100', + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + connector: { + id: '123', + fields: null, + name: 'connector', + type: ConnectorTypes.jira, + }, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(2); + expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "connector", + "type": ".jira", + } + `); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "123", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts new file mode 100644 index 0000000000000..8296de57b37a9 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { addOwnerToSO, SanitizedCaseOwner } from '.'; +import { + SavedObjectUnsanitizedDoc, + SavedObjectSanitizedDoc, +} from '../../../../../../src/core/server'; +import { ESConnectorFields } from '../../services'; +import { ConnectorTypes, CaseType } from '../../../common'; +import { transformConnectorIdToReference, transformPushConnectorIdToReference } from './utils'; + +interface UnsanitizedCaseConnector { + connector_id: string; +} + +interface SanitizedCaseConnector { + connector: { + id: string; + name: string | null; + type: string | null; + fields: null | ESConnectorFields; + }; +} + +interface SanitizedCaseSettings { + settings: { + syncAlerts: boolean; + }; +} + +interface SanitizedCaseType { + type: string; +} + +interface ConnectorIdFields { + connector?: { id?: string }; + external_service?: { connector_id?: string | null } | null; +} + +export const caseConnectorIdMigration = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectSanitizedDoc => { + // removing the id field since it will be stored in the references instead + const { connector, external_service, ...restAttributes } = doc.attributes; + + const { transformedConnector, references: connectorReferences } = transformConnectorIdToReference( + connector + ); + + const { + transformedPushConnector, + references: pushConnectorReferences, + } = transformPushConnectorIdToReference(external_service); + + const { references = [] } = doc; + + return { + ...doc, + attributes: { + ...restAttributes, + ...transformedConnector, + ...transformedPushConnector, + }, + references: [...references, ...connectorReferences, ...pushConnectorReferences], + }; +}; + +export const caseMigrations = { + '7.10.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + const { connector_id, ...attributesWithoutConnectorId } = doc.attributes; + + return { + ...doc, + attributes: { + ...attributesWithoutConnectorId, + connector: { + id: connector_id ?? 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }, + references: doc.references || [], + }; + }, + '7.11.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + settings: { + syncAlerts: true, + }, + }, + references: doc.references || [], + }; + }, + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + const { fields, type } = doc.attributes.connector; + return { + ...doc, + attributes: { + ...doc.attributes, + type: CaseType.individual, + connector: { + ...doc.attributes.connector, + fields: + Array.isArray(fields) && fields.length > 0 && type === ConnectorTypes.serviceNowITSM + ? [...fields, { key: 'category', value: null }, { key: 'subcategory', value: null }] + : fields, + }, + }, + references: doc.references || [], + }; + }, + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, + '7.15.0': caseConnectorIdMigration, +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts new file mode 100644 index 0000000000000..4467b499817a5 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectSanitizedDoc } from 'kibana/server'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { + CASE_CONFIGURE_SAVED_OBJECT, + ConnectorTypes, + SECURITY_SOLUTION_OWNER, +} from '../../../common'; +import { getNoneCaseConnector, CONNECTOR_ID_REFERENCE_NAME } from '../../common'; +import { ESCaseConnectorWithId } from '../../services/test_utils'; +import { ESCasesConfigureAttributes } from '../../services/configure/types'; +import { configureConnectorIdMigration } from './configuration'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +const create_7_14_0_configSchema = (connector?: ESCaseConnectorWithId) => ({ + type: CASE_CONFIGURE_SAVED_OBJECT, + id: '1', + attributes: { + connector, + closure_type: 'close-by-pushing', + owner: SECURITY_SOLUTION_OWNER, + created_at: '2020-04-09T09:43:51.778Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2020-04-09T09:43:51.778Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, +}); + +describe('7.15.0 connector ID migration', () => { + it('does not create a reference when the connector ID is none', () => { + const configureSavedObject = create_7_14_0_configSchema(getNoneCaseConnector()); + + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + }); + + it('does not create a reference when the connector is undefined and defaults it to the none connector', () => { + const configureSavedObject = create_7_14_0_configSchema(); + + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); + + it('creates a reference using the connector id', () => { + const configureSavedObject = create_7_14_0_configSchema({ + id: '123', + fields: null, + name: 'connector', + type: ConnectorTypes.jira, + }); + + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references).toEqual([ + { id: '123', type: ACTION_SAVED_OBJECT_TYPE, name: CONNECTOR_ID_REFERENCE_NAME }, + ]); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + }); + + it('returns the other attributes and default connector when the connector is undefined', () => { + const configureSavedObject = create_7_14_0_configSchema(); + + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "closure_type": "close-by-pushing", + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "owner": "securitySolution", + "updated_at": "2020-04-09T09:43:51.778Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "id": "1", + "references": Array [], + "type": "cases-configure", + } + `); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts new file mode 100644 index 0000000000000..3209feb2a9a9b --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { + SavedObjectUnsanitizedDoc, + SavedObjectSanitizedDoc, +} from '../../../../../../src/core/server'; +import { ConnectorTypes } from '../../../common'; +import { addOwnerToSO, SanitizedCaseOwner } from '.'; +import { transformConnectorIdToReference } from './utils'; + +interface UnsanitizedConfigureConnector { + connector_id: string; + connector_name: string; +} + +interface SanitizedConfigureConnector { + connector: { + id: string; + name: string | null; + type: string | null; + fields: null; + }; +} + +export const configureConnectorIdMigration = ( + doc: SavedObjectUnsanitizedDoc<{ connector?: { id: string } }> +): SavedObjectSanitizedDoc => { + // removing the id field since it will be stored in the references instead + const { connector, ...restAttributes } = doc.attributes; + const { transformedConnector, references: connectorReferences } = transformConnectorIdToReference( + connector + ); + const { references = [] } = doc; + + return { + ...doc, + attributes: { + ...restAttributes, + ...transformedConnector, + }, + references: [...references, ...connectorReferences], + }; +}; + +export const configureMigrations = { + '7.10.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + const { connector_id, connector_name, ...restAttributes } = doc.attributes; + + return { + ...doc, + attributes: { + ...restAttributes, + connector: { + id: connector_id ?? 'none', + name: connector_name ?? 'none', + type: ConnectorTypes.none, + fields: null, + }, + }, + references: doc.references || [], + }; + }, + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, + '7.15.0': configureConnectorIdMigration, +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts similarity index 53% rename from x-pack/plugins/cases/server/saved_object_types/migrations.ts rename to x-pack/plugins/cases/server/saved_object_types/migrations/index.ts index e4b201b21b756..7be87c3abc989 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts @@ -7,42 +7,19 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; +import { + SavedObjectUnsanitizedDoc, + SavedObjectSanitizedDoc, +} from '../../../../../../src/core/server'; import { ConnectorTypes, CommentType, - CaseType, AssociationType, - ESConnectorFields, SECURITY_SOLUTION_OWNER, -} from '../../common'; - -interface UnsanitizedCaseConnector { - connector_id: string; -} - -interface UnsanitizedConfigureConnector { - connector_id: string; - connector_name: string; -} - -interface SanitizedCaseConnector { - connector: { - id: string; - name: string | null; - type: string | null; - fields: null | ESConnectorFields; - }; -} +} from '../../../common'; -interface SanitizedConfigureConnector { - connector: { - id: string; - name: string | null; - type: string | null; - fields: null; - }; -} +export { caseMigrations } from './cases'; +export { configureMigrations } from './configuration'; interface UserActions { action_field: string[]; @@ -50,21 +27,11 @@ interface UserActions { old_value: string; } -interface SanitizedCaseSettings { - settings: { - syncAlerts: boolean; - }; -} - -interface SanitizedCaseType { - type: string; -} - -interface SanitizedCaseOwner { +export interface SanitizedCaseOwner { owner: string; } -const addOwnerToSO = >( +export const addOwnerToSO = >( doc: SavedObjectUnsanitizedDoc ): SavedObjectSanitizedDoc => ({ ...doc, @@ -75,94 +42,6 @@ const addOwnerToSO = >( references: doc.references || [], }); -export const caseMigrations = { - '7.10.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { - const { connector_id, ...attributesWithoutConnectorId } = doc.attributes; - - return { - ...doc, - attributes: { - ...attributesWithoutConnectorId, - connector: { - id: connector_id ?? 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - }, - references: doc.references || [], - }; - }, - '7.11.0': ( - doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { - return { - ...doc, - attributes: { - ...doc.attributes, - settings: { - syncAlerts: true, - }, - }, - references: doc.references || [], - }; - }, - '7.12.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { - const { fields, type } = doc.attributes.connector; - return { - ...doc, - attributes: { - ...doc.attributes, - type: CaseType.individual, - connector: { - ...doc.attributes.connector, - fields: - Array.isArray(fields) && fields.length > 0 && type === ConnectorTypes.serviceNowITSM - ? [...fields, { key: 'category', value: null }, { key: 'subcategory', value: null }] - : fields, - }, - }, - references: doc.references || [], - }; - }, - '7.14.0': ( - doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { - return addOwnerToSO(doc); - }, -}; - -export const configureMigrations = { - '7.10.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { - const { connector_id, connector_name, ...restAttributes } = doc.attributes; - - return { - ...doc, - attributes: { - ...restAttributes, - connector: { - id: connector_id ?? 'none', - name: connector_name ?? 'none', - type: ConnectorTypes.none, - fields: null, - }, - }, - references: doc.references || [], - }; - }, - '7.14.0': ( - doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { - return addOwnerToSO(doc); - }, -}; - export const userActionsMigrations = { '7.10.0': (doc: SavedObjectUnsanitizedDoc): SavedObjectSanitizedDoc => { const { action_field, new_value, old_value, ...restAttributes } = doc.attributes; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts new file mode 100644 index 0000000000000..f591bef6b3236 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts @@ -0,0 +1,229 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { noneConnectorId } from '../../../common'; +import { createExternalService, createJiraConnector } from '../../services/test_utils'; +import { transformConnectorIdToReference, transformPushConnectorIdToReference } from './utils'; + +describe('migration utils', () => { + describe('transformConnectorIdToReference', () => { + it('returns the default none connector when the connector is undefined', () => { + expect(transformConnectorIdToReference().transformedConnector).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns the default none connector when the id is undefined', () => { + expect(transformConnectorIdToReference({ id: undefined }).transformedConnector) + .toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns the default none connector when the id is none', () => { + expect(transformConnectorIdToReference({ id: noneConnectorId }).transformedConnector) + .toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns the default none connector when the id is none and other fields are defined', () => { + expect( + transformConnectorIdToReference({ ...createJiraConnector(), id: noneConnectorId }) + .transformedConnector + ).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns an empty array of references when the connector is undefined', () => { + expect(transformConnectorIdToReference().references.length).toBe(0); + }); + + it('returns an empty array of references when the id is undefined', () => { + expect(transformConnectorIdToReference({ id: undefined }).references.length).toBe(0); + }); + + it('returns an empty array of references when the id is the none connector', () => { + expect(transformConnectorIdToReference({ id: noneConnectorId }).references.length).toBe(0); + }); + + it('returns an empty array of references when the id is the none connector and other fields are defined', () => { + expect( + transformConnectorIdToReference({ ...createJiraConnector(), id: noneConnectorId }) + .references.length + ).toBe(0); + }); + + it('returns a jira connector', () => { + const transformedFields = transformConnectorIdToReference(createJiraConnector()); + expect(transformedFields.transformedConnector).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + } + `); + expect(transformedFields.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + }); + + describe('transformPushConnectorIdToReference', () => { + it('sets external_service to null when it is undefined', () => { + expect(transformPushConnectorIdToReference().transformedPushConnector).toMatchInlineSnapshot(` + Object { + "external_service": null, + } + `); + }); + + it('sets external_service to null when it is null', () => { + expect(transformPushConnectorIdToReference(null).transformedPushConnector) + .toMatchInlineSnapshot(` + Object { + "external_service": null, + } + `); + }); + + it('returns an object when external_service is defined but connector_id is undefined', () => { + expect( + transformPushConnectorIdToReference({ connector_id: undefined }).transformedPushConnector + ).toMatchInlineSnapshot(` + Object { + "external_service": Object {}, + } + `); + }); + + it('returns an object when external_service is defined but connector_id is null', () => { + expect(transformPushConnectorIdToReference({ connector_id: null }).transformedPushConnector) + .toMatchInlineSnapshot(` + Object { + "external_service": Object {}, + } + `); + }); + + it('returns an object when external_service is defined but connector_id is none', () => { + const otherFields = { otherField: 'hi' }; + + expect( + transformPushConnectorIdToReference({ ...otherFields, connector_id: noneConnectorId }) + .transformedPushConnector + ).toMatchInlineSnapshot(` + Object { + "external_service": Object { + "otherField": "hi", + }, + } + `); + }); + + it('returns an empty array of references when the external_service is undefined', () => { + expect(transformPushConnectorIdToReference().references.length).toBe(0); + }); + + it('returns an empty array of references when the external_service is null', () => { + expect(transformPushConnectorIdToReference(null).references.length).toBe(0); + }); + + it('returns an empty array of references when the connector_id is undefined', () => { + expect( + transformPushConnectorIdToReference({ connector_id: undefined }).references.length + ).toBe(0); + }); + + it('returns an empty array of references when the connector_id is null', () => { + expect( + transformPushConnectorIdToReference({ connector_id: undefined }).references.length + ).toBe(0); + }); + + it('returns an empty array of references when the connector_id is the none connector', () => { + expect( + transformPushConnectorIdToReference({ connector_id: noneConnectorId }).references.length + ).toBe(0); + }); + + it('returns an empty array of references when the connector_id is the none connector and other fields are defined', () => { + expect( + transformPushConnectorIdToReference({ + ...createExternalService(), + connector_id: noneConnectorId, + }).references.length + ).toBe(0); + }); + + it('returns the external_service connector', () => { + const transformedFields = transformPushConnectorIdToReference(createExternalService()); + expect(transformedFields.transformedPushConnector).toMatchInlineSnapshot(` + Object { + "external_service": Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + } + `); + expect(transformedFields.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts new file mode 100644 index 0000000000000..0100a04cde679 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { noneConnectorId } from '../../../common'; +import { SavedObjectReference } from '../../../../../../src/core/server'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { + getNoneCaseConnector, + CONNECTOR_ID_REFERENCE_NAME, + PUSH_CONNECTOR_ID_REFERENCE_NAME, +} from '../../common'; + +export const transformConnectorIdToReference = (connector?: { + id?: string; +}): { transformedConnector: Record; references: SavedObjectReference[] } => { + const { id: connectorId, ...restConnector } = connector ?? {}; + + const references = createConnectorReference( + connectorId, + ACTION_SAVED_OBJECT_TYPE, + CONNECTOR_ID_REFERENCE_NAME + ); + + const { id: ignoreNoneId, ...restNoneConnector } = getNoneCaseConnector(); + const connectorFieldsToReturn = + connector && references.length > 0 ? restConnector : restNoneConnector; + + return { + transformedConnector: { + connector: connectorFieldsToReturn, + }, + references, + }; +}; + +const createConnectorReference = ( + id: string | null | undefined, + type: string, + name: string +): SavedObjectReference[] => { + return id && id !== noneConnectorId + ? [ + { + id, + type, + name, + }, + ] + : []; +}; + +export const transformPushConnectorIdToReference = ( + external_service?: { connector_id?: string | null } | null +): { transformedPushConnector: Record; references: SavedObjectReference[] } => { + const { connector_id: pushConnectorId, ...restExternalService } = external_service ?? {}; + + const references = createConnectorReference( + pushConnectorId, + ACTION_SAVED_OBJECT_TYPE, + PUSH_CONNECTOR_ID_REFERENCE_NAME + ); + + return { + transformedPushConnector: { external_service: external_service ? restExternalService : null }, + references, + }; +}; diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts new file mode 100644 index 0000000000000..bf7eeda7e0e2e --- /dev/null +++ b/x-pack/plugins/cases/server/services/cases/index.test.ts @@ -0,0 +1,1167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * This test file references connector_id and connector.id. The connector_id is a field within the external_service + * object. It holds the action connector's id that was used to push the case to the external service. The connector.id + * field also holds an action connector's id. This id is the currently configured connector for the case. The next + * time the case is pushed it will use this connector to push the case. The connector_id can be different from the + * connector.id. + */ + +import { + CaseAttributes, + CaseConnector, + CaseFullExternalService, + CASE_SAVED_OBJECT, +} from '../../../common'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { + SavedObject, + SavedObjectReference, + SavedObjectsCreateOptions, + SavedObjectsFindResult, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, +} from 'kibana/server'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { loggerMock } from '@kbn/logging/target/mocks'; +import { getNoneCaseConnector, CONNECTOR_ID_REFERENCE_NAME } from '../../common'; +import { CasesService } from '.'; +import { + createESJiraConnector, + createJiraConnector, + ESCaseConnectorWithId, + createExternalService, + createSavedObjectReferences, + createCaseSavedObjectResponse, + basicCaseFields, +} from '../test_utils'; +import { ESCaseAttributes } from './types'; + +const createUpdateSOResponse = ({ + connector, + externalService, +}: { + connector?: ESCaseConnectorWithId; + externalService?: CaseFullExternalService; +} = {}): SavedObjectsUpdateResponse => { + const references: SavedObjectReference[] = createSavedObjectReferences({ + connector, + externalService, + }); + + let attributes: Partial = {}; + + if (connector) { + const { id, ...restConnector } = connector; + attributes = { ...attributes, connector: { ...restConnector } }; + } + + if (externalService) { + const { connector_id: id, ...restService } = externalService; + attributes = { ...attributes, external_service: { ...restService } }; + } else if (externalService === null) { + attributes = { ...attributes, external_service: null }; + } + + return { + type: CASE_SAVED_OBJECT, + id: '1', + attributes, + references, + }; +}; + +const createFindSO = ( + params: { + connector?: ESCaseConnectorWithId; + externalService?: CaseFullExternalService; + } = {} +): SavedObjectsFindResult => ({ + ...createCaseSavedObjectResponse(params), + score: 0, +}); + +const createSOFindResponse = (savedObjects: Array>) => ({ + saved_objects: savedObjects, + total: savedObjects.length, + per_page: savedObjects.length, + page: 1, +}); + +const createCaseUpdateParams = ( + connector?: CaseConnector, + externalService?: CaseFullExternalService +): Partial => ({ + ...(connector && { connector }), + ...(externalService && { external_service: externalService }), +}); + +const createCasePostParams = ( + connector: CaseConnector, + externalService?: CaseFullExternalService +): CaseAttributes => ({ + ...basicCaseFields, + connector, + ...(externalService ? { external_service: externalService } : { external_service: null }), +}); + +const createCasePatchParams = ({ + connector, + externalService, +}: { + connector?: CaseConnector; + externalService?: CaseFullExternalService; +} = {}): Partial => ({ + ...basicCaseFields, + connector, + ...(externalService && { external_service: externalService }), +}); + +describe('CasesService', () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + const mockLogger = loggerMock.create(); + + let service: CasesService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new CasesService(mockLogger); + }); + + describe('transforms the external model to the Elasticsearch model', () => { + describe('patch', () => { + it('includes the passed in fields', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), + originalCase: {} as SavedObject, + }); + + const { + connector: ignoreConnector, + external_service: ignoreExternalService, + ...restUpdateAttributes + } = unsecuredSavedObjectsClient.update.mock.calls[0][2] as Partial; + expect(restUpdateAttributes).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('transforms the connector.fields to an array of key/value pairs', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), + originalCase: {} as SavedObject, + }); + + const { connector } = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + expect(connector?.fields).toMatchInlineSnapshot(` + Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ] + `); + }); + + it('preserves the connector fields but does not have the id', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), + originalCase: {} as SavedObject, + }); + + const { connector } = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + expect(connector).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ], + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('removes the connector id and adds it to the references', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(createJiraConnector()), + originalCase: {} as SavedObject, + }); + + const updateAttributes = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + expect(updateAttributes.connector).not.toHaveProperty('id'); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('removes the external_service connector_id and adds it to the references', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(getNoneCaseConnector(), createExternalService()), + originalCase: {} as SavedObject, + }); + + const updateAttributes = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + expect(updateAttributes.external_service).not.toHaveProperty('connector_id'); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('builds references for external service connector id, case connector id, and includes the existing references', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), + originalCase: { + references: [{ id: 'a', name: 'awesome', type: 'hello' }], + } as SavedObject, + }); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "a", + "name": "awesome", + "type": "hello", + }, + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('builds references for connector_id and preserves the existing connector.id reference', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePatchParams({ externalService: createExternalService() }), + originalCase: { + references: [ + { id: '1', name: CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ], + } as SavedObject, + }); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('preserves the external_service fields except for the connector_id', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(getNoneCaseConnector(), createExternalService()), + originalCase: {} as SavedObject, + }); + + const updateAttributes = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + expect(updateAttributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('creates an empty updatedAttributes when there is no connector or external_service as input', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot( + `Object {}` + ); + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toBeUndefined(); + }); + + it('creates a updatedAttributes field with the none connector', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(getNoneCaseConnector()), + originalCase: {} as SavedObject, + }); + + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Array [], + "name": "none", + "type": ".none", + }, + } + `); + }); + }); + + describe('post', () => { + it('creates a null external_service field when the attribute was null in the creation parameters', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(createJiraConnector()), + id: '1', + }); + + const postAttributes = unsecuredSavedObjectsClient.create.mock + .calls[0][1] as CaseAttributes; + expect(postAttributes.external_service).toMatchInlineSnapshot(`null`); + }); + + it('includes the creation attributes excluding the connector.id and connector_id', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(createJiraConnector(), createExternalService()), + id: '1', + }); + + const creationAttributes = unsecuredSavedObjectsClient.create.mock + .calls[0][1] as ESCaseAttributes; + expect(creationAttributes.connector).not.toHaveProperty('id'); + expect(creationAttributes.external_service).not.toHaveProperty('connector_id'); + expect(creationAttributes).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ], + "name": ".jira", + "type": ".jira", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ], + } + `); + }); + + it('moves the connector.id and connector_id to the references', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(createJiraConnector(), createExternalService()), + id: '1', + }); + + const creationOptions = unsecuredSavedObjectsClient.create.mock + .calls[0][2] as SavedObjectsCreateOptions; + expect(creationOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('sets fields to an empty array when it is not included with the connector', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams( + createJiraConnector({ setFieldsToNull: true }), + createExternalService() + ), + id: '1', + }); + + const postAttributes = unsecuredSavedObjectsClient.create.mock + .calls[0][1] as CaseAttributes; + expect(postAttributes.connector.fields).toEqual([]); + }); + + it('does not create a reference for a none connector', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(getNoneCaseConnector()), + id: '1', + }); + + const creationOptions = unsecuredSavedObjectsClient.create.mock + .calls[0][2] as SavedObjectsCreateOptions; + expect(creationOptions.references).toEqual([]); + }); + + it('does not create a reference for an external_service field that is null', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(getNoneCaseConnector()), + id: '1', + }); + + const creationOptions = unsecuredSavedObjectsClient.create.mock + .calls[0][2] as SavedObjectsCreateOptions; + expect(creationOptions.references).toEqual([]); + }); + }); + }); + + describe('transforms the Elasticsearch model to the external model', () => { + describe('bulkPatch', () => { + it('formats the update saved object by including the passed in fields and transforming the connector.fields', async () => { + unsecuredSavedObjectsClient.bulkUpdate.mockReturnValue( + Promise.resolve({ + saved_objects: [ + createCaseSavedObjectResponse({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }), + createCaseSavedObjectResponse({ + connector: createESJiraConnector({ id: '2' }), + externalService: createExternalService({ connector_id: '200' }), + }), + ], + }) + ); + + const res = await service.patchCases({ + unsecuredSavedObjectsClient, + cases: [ + { + caseId: '1', + updatedAttributes: createCasePostParams( + createJiraConnector(), + createExternalService() + ), + originalCase: {} as SavedObject, + }, + ], + }); + + expect(res.saved_objects[0].attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "1", + "name": ".jira", + "type": ".jira", + } + `); + expect( + res.saved_objects[1].attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"200"`); + + expect(res.saved_objects[1].attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "2", + "name": ".jira", + "type": ".jira", + } + `); + expect( + res.saved_objects[0].attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"100"`); + }); + }); + + describe('patch', () => { + it('returns an object with a none connector and without a reference when it was set to a none connector in the update', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateSOResponse({ connector: getNoneCaseConnector() })) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + } + `); + expect(res.references).toMatchInlineSnapshot(`Array []`); + }); + + it('returns an object with a null external service and without a reference when it was set to null in the update', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateSOResponse({ externalService: null })) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes).toMatchInlineSnapshot(` + Object { + "external_service": null, + } + `); + expect(res.references).toMatchInlineSnapshot(`Array []`); + }); + + it('returns an empty object when neither the connector or external service was updated', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateSOResponse()) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes).toMatchInlineSnapshot(`Object {}`); + expect(res.references).toMatchInlineSnapshot(`Array []`); + }); + + it('returns an undefined connector if it is not returned by the update', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "attributes": Object {}, + } + `); + }); + + it('returns the default none connector when it cannot find the reference', async () => { + const { name, type, fields } = createESJiraConnector(); + const returnValue: SavedObjectsUpdateResponse = { + type: CASE_SAVED_OBJECT, + id: '1', + attributes: { + connector: { + name, + type, + fields, + }, + }, + version: '1', + references: undefined, + }; + + unsecuredSavedObjectsClient.update.mockReturnValue(Promise.resolve(returnValue)); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('returns a null external service connector when it cannot find the reference', async () => { + const { connector_id: id, ...restExternalConnector } = createExternalService()!; + const returnValue: SavedObjectsUpdateResponse = { + type: CASE_SAVED_OBJECT, + id: '1', + attributes: { + external_service: restExternalConnector, + }, + version: '1', + references: undefined, + }; + + unsecuredSavedObjectsClient.update.mockReturnValue(Promise.resolve(returnValue)); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes.external_service?.connector_id).toBeNull(); + }); + + it('returns the saved object fields when it cannot find the reference for connector_id', async () => { + const { connector_id: id, ...restExternalConnector } = createExternalService()!; + const returnValue: SavedObjectsUpdateResponse = { + type: CASE_SAVED_OBJECT, + id: '1', + attributes: { + external_service: restExternalConnector, + }, + version: '1', + references: undefined, + }; + + unsecuredSavedObjectsClient.update.mockReturnValue(Promise.resolve(returnValue)); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "external_service": Object { + "connector_id": null, + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + }, + "id": "1", + "references": undefined, + "type": "cases", + "version": "1", + } + `); + }); + + it('returns the connector.id after finding the reference', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateSOResponse({ connector: createESJiraConnector() })) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "1", + "name": ".jira", + "type": ".jira", + } + `); + expect(res.attributes.connector?.id).toMatchInlineSnapshot(`"1"`); + }); + + it('returns the external_service connector_id after finding the reference', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateSOResponse({ externalService: createExternalService() })) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_id": "100", + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + expect(res.attributes.external_service?.connector_id).toMatchInlineSnapshot(`"100"`); + }); + }); + + describe('post', () => { + it('includes the connector.id and connector_id fields in the response', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve( + createCaseSavedObjectResponse({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }) + ) + ); + + const res = await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(getNoneCaseConnector()), + id: '1', + }); + + expect(res.attributes.connector.id).toMatchInlineSnapshot(`"1"`); + expect(res.attributes.external_service?.connector_id).toMatchInlineSnapshot(`"100"`); + }); + }); + + describe('find', () => { + it('includes the connector.id and connector_id field in the response', async () => { + const findMockReturn = createSOFindResponse([ + createFindSO({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }), + createFindSO(), + ]); + unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); + + const res = await service.findCases({ unsecuredSavedObjectsClient }); + expect(res.saved_objects[0].attributes.connector.id).toMatchInlineSnapshot(`"1"`); + expect( + res.saved_objects[0].attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"100"`); + }); + + it('includes the saved object find response fields in the result', async () => { + const findMockReturn = createSOFindResponse([ + createFindSO({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }), + createFindSO(), + ]); + unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); + + const res = await service.findCases({ unsecuredSavedObjectsClient }); + const { saved_objects: ignored, ...findResponseFields } = res; + expect(findResponseFields).toMatchInlineSnapshot(` + Object { + "page": 1, + "per_page": 2, + "total": 2, + } + `); + }); + }); + + describe('bulkGet', () => { + it('includes the connector.id and connector_id fields in the response', async () => { + unsecuredSavedObjectsClient.bulkGet.mockReturnValue( + Promise.resolve({ + saved_objects: [ + createCaseSavedObjectResponse({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }), + createCaseSavedObjectResponse({ + connector: createESJiraConnector({ id: '2' }), + externalService: createExternalService({ connector_id: '200' }), + }), + ], + }) + ); + + const res = await service.getCases({ unsecuredSavedObjectsClient, caseIds: ['a'] }); + + expect(res.saved_objects[0].attributes.connector.id).toMatchInlineSnapshot(`"1"`); + expect( + res.saved_objects[1].attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"200"`); + + expect(res.saved_objects[1].attributes.connector.id).toMatchInlineSnapshot(`"2"`); + expect( + res.saved_objects[1].attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"200"`); + }); + }); + + describe('get', () => { + it('includes the connector.id and connector_id fields in the response', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve( + createCaseSavedObjectResponse({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }) + ) + ); + + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.connector.id).toMatchInlineSnapshot(`"1"`); + expect(res.attributes.external_service?.connector_id).toMatchInlineSnapshot(`"100"`); + }); + + it('defaults to the none connector when the connector reference cannot be found', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve( + createCaseSavedObjectResponse({ externalService: createExternalService() }) + ) + ); + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('sets external services connector_id to null when the connector id cannot be found in the references', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve(createCaseSavedObjectResponse()) + ); + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.external_service?.connector_id).toMatchInlineSnapshot(`null`); + }); + + it('includes the external services fields when the connector id cannot be found in the references', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve(createCaseSavedObjectResponse()) + ); + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_id": null, + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('defaults to the none connector and null external_services when attributes is undefined', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve(({ + references: [ + { + id: '1', + name: CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ], + } as unknown) as SavedObject) + ); + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + + expect(res.attributes.external_service).toMatchInlineSnapshot(`null`); + }); + + it('returns a null external_services when it is already null', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve({ + attributes: { external_service: null }, + } as SavedObject) + ); + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + + expect(res.attributes.external_service).toMatchInlineSnapshot(`null`); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/cases/index.ts b/x-pack/plugins/cases/server/services/cases/index.ts index a0e4380f95640..72c2033f83535 100644 --- a/x-pack/plugins/cases/server/services/cases/index.ts +++ b/x-pack/plugins/cases/server/services/cases/index.ts @@ -14,6 +14,8 @@ import { SavedObjectsFindResponse, SavedObjectsBulkResponse, SavedObjectsFindResult, + SavedObjectsBulkUpdateResponse, + SavedObjectsUpdateResponse, } from 'kibana/server'; import type { estypes } from '@elastic/elasticsearch'; @@ -32,7 +34,6 @@ import { CommentAttributes, CommentType, ENABLE_CASE_CONNECTOR, - ESCaseAttributes, GetCaseIdsByAlertIdAggs, MAX_CONCURRENT_SEARCHES, MAX_DOCS_PER_PAGE, @@ -41,6 +42,7 @@ import { SubCaseAttributes, SubCaseResponse, User, + CaseAttributes, } from '../../../common'; import { defaultSortField, @@ -54,6 +56,15 @@ import { ClientArgs } from '..'; import { combineFilters } from '../../client/utils'; import { includeFieldsRequiredForAuthentication } from '../../authorization/utils'; import { EnsureSOAuthCallback } from '../../authorization'; +import { + transformSavedObjectToExternalModel, + transformAttributesToESModel, + transformUpdateResponseToExternalModel, + transformUpdateResponsesToExternalModels, + transformBulkResponseToExternalModel, + transformFindResponseToExternalModel, +} from './transform'; +import { ESCaseAttributes } from './types'; interface GetCaseIdsByAlertIdArgs extends ClientArgs { alertId: string; @@ -111,7 +122,7 @@ interface FindSubCasesStatusStats { } interface PostCaseArgs extends ClientArgs { - attributes: ESCaseAttributes; + attributes: CaseAttributes; id: string; } @@ -123,7 +134,8 @@ interface CreateSubCaseArgs extends ClientArgs { interface PatchCase { caseId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; + originalCase: SavedObject; version?: string; } type PatchCaseArgs = PatchCase & ClientArgs; @@ -168,7 +180,7 @@ interface FindCommentsByAssociationArgs { } interface Collection { - case: SavedObjectsFindResult; + case: SavedObjectsFindResult; subCases?: SubCaseResponse[]; } @@ -713,10 +725,14 @@ export class CasesService { public async getCase({ unsecuredSavedObjectsClient, id: caseId, - }: GetCaseArgs): Promise> { + }: GetCaseArgs): Promise> { try { this.log.debug(`Attempting to GET case ${caseId}`); - return await unsecuredSavedObjectsClient.get(CASE_SAVED_OBJECT, caseId); + const caseSavedObject = await unsecuredSavedObjectsClient.get( + CASE_SAVED_OBJECT, + caseId + ); + return transformSavedObjectToExternalModel(caseSavedObject); } catch (error) { this.log.error(`Error on GET case ${caseId}: ${error}`); throw error; @@ -753,12 +769,13 @@ export class CasesService { public async getCases({ unsecuredSavedObjectsClient, caseIds, - }: GetCasesArgs): Promise> { + }: GetCasesArgs): Promise> { try { this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); - return await unsecuredSavedObjectsClient.bulkGet( + const cases = await unsecuredSavedObjectsClient.bulkGet( caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) ); + return transformBulkResponseToExternalModel(cases); } catch (error) { this.log.error(`Error on GET cases ${caseIds.join(', ')}: ${error}`); throw error; @@ -768,14 +785,15 @@ export class CasesService { public async findCases({ unsecuredSavedObjectsClient, options, - }: FindCasesArgs): Promise> { + }: FindCasesArgs): Promise> { try { this.log.debug(`Attempting to find cases`); - return await unsecuredSavedObjectsClient.find({ + const cases = await unsecuredSavedObjectsClient.find({ sortField: defaultSortField, ...options, type: CASE_SAVED_OBJECT, }); + return transformFindResponseToExternalModel(cases); } catch (error) { this.log.error(`Error on find cases: ${error}`); throw error; @@ -1041,14 +1059,20 @@ export class CasesService { } } - public async postNewCase({ unsecuredSavedObjectsClient, attributes, id }: PostCaseArgs) { + public async postNewCase({ + unsecuredSavedObjectsClient, + attributes, + id, + }: PostCaseArgs): Promise> { try { this.log.debug(`Attempting to POST a new case`); - return await unsecuredSavedObjectsClient.create( + const transformedAttributes = transformAttributesToESModel(attributes); + const createdCase = await unsecuredSavedObjectsClient.create( CASE_SAVED_OBJECT, - attributes, - { id } + transformedAttributes.attributes, + { id, references: transformedAttributes.referenceHandler.build() } ); + return transformSavedObjectToExternalModel(createdCase); } catch (error) { this.log.error(`Error on POST a new case: ${error}`); throw error; @@ -1059,33 +1083,52 @@ export class CasesService { unsecuredSavedObjectsClient, caseId, updatedAttributes, + originalCase, version, - }: PatchCaseArgs) { + }: PatchCaseArgs): Promise> { try { this.log.debug(`Attempting to UPDATE case ${caseId}`); - return await unsecuredSavedObjectsClient.update( + const transformedAttributes = transformAttributesToESModel(updatedAttributes); + + const updatedCase = await unsecuredSavedObjectsClient.update( CASE_SAVED_OBJECT, caseId, - { ...updatedAttributes }, - { version } + transformedAttributes.attributes, + { + version, + references: transformedAttributes.referenceHandler.build(originalCase.references), + } ); + + return transformUpdateResponseToExternalModel(updatedCase); } catch (error) { this.log.error(`Error on UPDATE case ${caseId}: ${error}`); throw error; } } - public async patchCases({ unsecuredSavedObjectsClient, cases }: PatchCasesArgs) { + public async patchCases({ + unsecuredSavedObjectsClient, + cases, + }: PatchCasesArgs): Promise> { try { this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); - return await unsecuredSavedObjectsClient.bulkUpdate( - cases.map((c) => ({ + + const bulkUpdate = cases.map(({ caseId, updatedAttributes, version, originalCase }) => { + const { attributes, referenceHandler } = transformAttributesToESModel(updatedAttributes); + return { type: CASE_SAVED_OBJECT, - id: c.caseId, - attributes: c.updatedAttributes, - version: c.version, - })) + id: caseId, + attributes, + references: referenceHandler.build(originalCase.references), + version, + }; + }); + + const updatedCases = await unsecuredSavedObjectsClient.bulkUpdate( + bulkUpdate ); + return transformUpdateResponsesToExternalModels(updatedCases); } catch (error) { this.log.error(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); throw error; diff --git a/x-pack/plugins/cases/server/services/cases/transform.test.ts b/x-pack/plugins/cases/server/services/cases/transform.test.ts new file mode 100644 index 0000000000000..96312d00b37dd --- /dev/null +++ b/x-pack/plugins/cases/server/services/cases/transform.test.ts @@ -0,0 +1,414 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + createCaseSavedObjectResponse, + createESJiraConnector, + createExternalService, + createJiraConnector, +} from '../test_utils'; +import { + transformAttributesToESModel, + transformSavedObjectToExternalModel, + transformUpdateResponseToExternalModel, +} from './transform'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { ConnectorTypes } from '../../../common'; +import { + getNoneCaseConnector, + CONNECTOR_ID_REFERENCE_NAME, + PUSH_CONNECTOR_ID_REFERENCE_NAME, +} from '../../common'; + +describe('case transforms', () => { + describe('transformUpdateResponseToExternalModel', () => { + it('does not return the connector field if it is undefined', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: {}, + references: undefined, + }).attributes + ).not.toHaveProperty('connector'); + }); + + it('does not return the external_service field if it is undefined', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: {}, + references: undefined, + }).attributes + ).not.toHaveProperty('external_service'); + }); + + it('return a null external_service field if it is null', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + external_service: null, + }, + references: undefined, + }).attributes.external_service + ).toBeNull(); + }); + + it('return a null external_service.connector_id field if it is none', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + external_service: createExternalService({ connector_id: 'none' }), + }, + references: undefined, + }).attributes.external_service?.connector_id + ).toBeNull(); + }); + + it('return the external_service fields if it is populated', () => { + const { connector_id: ignore, ...restExternalService } = createExternalService()!; + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + external_service: restExternalService, + }, + references: undefined, + }).attributes.external_service + ).toMatchInlineSnapshot(` + Object { + "connector_id": null, + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('populates the connector_id field when it finds a reference', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + external_service: createExternalService(), + }, + references: [ + { id: '1', name: PUSH_CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ], + }).attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"1"`); + }); + + it('populates the external_service fields when it finds a reference', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + external_service: createExternalService(), + }, + references: [ + { id: '1', name: PUSH_CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ], + }).attributes.external_service + ).toMatchInlineSnapshot(` + Object { + "connector_id": "1", + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('populates the connector fields when it finds a reference', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + connector: { + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + fields: [{ key: 'issueType', value: 'bug' }], + }, + }, + references: [ + { id: '1', name: CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ], + }).attributes.connector + ).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + }, + "id": "1", + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('returns the none connector when it cannot find the reference', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + connector: { + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + fields: [{ key: 'issueType', value: 'bug' }], + }, + }, + references: undefined, + }).attributes.connector + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + }); + + describe('transformAttributesToESModel', () => { + it('does not return the external_service field when it is undefined', () => { + expect( + transformAttributesToESModel({ + external_service: undefined, + }).attributes + ).not.toHaveProperty('external_service'); + }); + + it('creates an undefined reference when external_service is undefined and the original reference is undefined', () => { + expect( + transformAttributesToESModel({ + external_service: undefined, + }).referenceHandler.build() + ).toBeUndefined(); + }); + + it('returns a null external_service when it is null', () => { + expect( + transformAttributesToESModel({ + external_service: null, + }).attributes.external_service + ).toBeNull(); + }); + + it('creates an undefined reference when external_service is null and the original reference is undefined', () => { + expect( + transformAttributesToESModel({ + external_service: null, + }).referenceHandler.build() + ).toBeUndefined(); + }); + + it('returns the external_service fields except for the connector_id', () => { + const transformedAttributes = transformAttributesToESModel({ + external_service: createExternalService(), + }); + + expect(transformedAttributes.attributes).toMatchInlineSnapshot(` + Object { + "external_service": Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + } + `); + expect(transformedAttributes.attributes.external_service).not.toHaveProperty('connector_id'); + expect(transformedAttributes.referenceHandler.build()).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('creates an empty references array to delete the connector_id when connector_id is null and the original references is undefined', () => { + const transformedAttributes = transformAttributesToESModel({ + external_service: createExternalService({ connector_id: null }), + }); + + expect(transformedAttributes.referenceHandler.build()).toEqual([]); + }); + + it('does not return the connector when it is undefined', () => { + expect(transformAttributesToESModel({ connector: undefined }).attributes).not.toHaveProperty( + 'connector' + ); + }); + + it('constructs an undefined reference when the connector is undefined and the original reference is undefined', () => { + expect( + transformAttributesToESModel({ connector: undefined }).referenceHandler.build() + ).toBeUndefined(); + }); + + it('returns a jira connector', () => { + const transformedAttributes = transformAttributesToESModel({ + connector: createJiraConnector(), + }); + + expect(transformedAttributes.attributes).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ], + "name": ".jira", + "type": ".jira", + }, + } + `); + expect(transformedAttributes.attributes.connector).not.toHaveProperty('id'); + expect(transformedAttributes.referenceHandler.build()).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('returns a none connector without a reference', () => { + const transformedAttributes = transformAttributesToESModel({ + connector: getNoneCaseConnector(), + }); + + expect(transformedAttributes.attributes).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Array [], + "name": "none", + "type": ".none", + }, + } + `); + expect(transformedAttributes.attributes.connector).not.toHaveProperty('id'); + expect(transformedAttributes.referenceHandler.build()).toEqual([]); + }); + }); + + describe('transformSavedObjectToExternalModel', () => { + it('returns the default none connector when it cannot find the reference', () => { + expect( + transformSavedObjectToExternalModel( + createCaseSavedObjectResponse({ connector: getNoneCaseConnector() }) + ).attributes.connector + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('returns a jira connector', () => { + expect( + transformSavedObjectToExternalModel( + createCaseSavedObjectResponse({ connector: createESJiraConnector() }) + ).attributes.connector + ).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "1", + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('sets external_service to null when it is null', () => { + expect( + transformSavedObjectToExternalModel( + createCaseSavedObjectResponse({ externalService: null }) + ).attributes.external_service + ).toBeNull(); + }); + + it('sets external_service.connector_id to null when a reference cannot be found', () => { + const transformedSO = transformSavedObjectToExternalModel( + createCaseSavedObjectResponse({ + externalService: createExternalService({ connector_id: null }), + }) + ); + + expect(transformedSO.attributes.external_service?.connector_id).toBeNull(); + expect(transformedSO.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_id": null, + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/cases/transform.ts b/x-pack/plugins/cases/server/services/cases/transform.ts new file mode 100644 index 0000000000000..00b20a6290860 --- /dev/null +++ b/x-pack/plugins/cases/server/services/cases/transform.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { + SavedObject, + SavedObjectReference, + SavedObjectsBulkResponse, + SavedObjectsBulkUpdateResponse, + SavedObjectsFindResponse, + SavedObjectsUpdateResponse, +} from 'kibana/server'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { ESCaseAttributes, ExternalServicesWithoutConnectorId } from './types'; +import { CONNECTOR_ID_REFERENCE_NAME, PUSH_CONNECTOR_ID_REFERENCE_NAME } from '../../common'; +import { CaseAttributes, CaseFullExternalService } from '../../../common'; +import { + findConnectorIdReference, + transformFieldsToESModel, + transformESConnectorOrUseDefault, + transformESConnectorToExternalModel, +} from '../transform'; +import { ConnectorReferenceHandler } from '../connector_reference_handler'; + +export function transformUpdateResponsesToExternalModels( + response: SavedObjectsBulkUpdateResponse +): SavedObjectsBulkUpdateResponse { + return { + ...response, + saved_objects: response.saved_objects.map((so) => ({ + ...so, + ...transformUpdateResponseToExternalModel(so), + })), + }; +} + +export function transformUpdateResponseToExternalModel( + updatedCase: SavedObjectsUpdateResponse +): SavedObjectsUpdateResponse { + const { connector, external_service, ...restUpdateAttributes } = updatedCase.attributes ?? {}; + + const transformedConnector = transformESConnectorToExternalModel({ + // if the saved object had an error the attributes field will not exist + connector, + references: updatedCase.references, + referenceName: CONNECTOR_ID_REFERENCE_NAME, + }); + + let externalService: CaseFullExternalService | null | undefined; + + // if external_service is not defined then we don't want to include it in the response since it wasn't passed it as an + // attribute to update + if (external_service !== undefined) { + externalService = transformESExternalService(external_service, updatedCase.references); + } + + return { + ...updatedCase, + attributes: { + ...restUpdateAttributes, + ...(transformedConnector && { connector: transformedConnector }), + // if externalService is null that means we intentionally updated it to null within ES so return that as a valid value + ...(externalService !== undefined && { external_service: externalService }), + }, + }; +} + +export function transformAttributesToESModel( + caseAttributes: CaseAttributes +): { + attributes: ESCaseAttributes; + referenceHandler: ConnectorReferenceHandler; +}; +export function transformAttributesToESModel( + caseAttributes: Partial +): { + attributes: Partial; + referenceHandler: ConnectorReferenceHandler; +}; +export function transformAttributesToESModel( + caseAttributes: Partial +): { + attributes: Partial; + referenceHandler: ConnectorReferenceHandler; +} { + const { connector, external_service, ...restAttributes } = caseAttributes; + const { connector_id: pushConnectorId, ...restExternalService } = external_service ?? {}; + + const transformedConnector = { + ...(connector && { + connector: { + name: connector.name, + type: connector.type, + fields: transformFieldsToESModel(connector), + }, + }), + }; + + const transformedExternalService = { + ...(external_service + ? { external_service: restExternalService } + : external_service === null + ? { external_service: null } + : {}), + }; + + return { + attributes: { + ...restAttributes, + ...transformedConnector, + ...transformedExternalService, + }, + referenceHandler: buildReferenceHandler(connector?.id, pushConnectorId), + }; +} + +function buildReferenceHandler( + connectorId?: string, + pushConnectorId?: string | null +): ConnectorReferenceHandler { + return new ConnectorReferenceHandler([ + { id: connectorId, name: CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + { id: pushConnectorId, name: PUSH_CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ]); +} + +/** + * Until Kibana uses typescript 4.3 or higher we'll have to keep these functions separate instead of using an overload + * definition like this: + * + * export function transformArrayResponseToExternalModel( + * response: SavedObjectsBulkResponse | SavedObjectsFindResponse + * ): SavedObjectsBulkResponse | SavedObjectsFindResponse { + * + * See this issue for more details: https://stackoverflow.com/questions/49510832/typescript-how-to-map-over-union-array-type + */ + +export function transformBulkResponseToExternalModel( + response: SavedObjectsBulkResponse +): SavedObjectsBulkResponse { + return { + ...response, + saved_objects: response.saved_objects.map((so) => ({ + ...so, + ...transformSavedObjectToExternalModel(so), + })), + }; +} + +export function transformFindResponseToExternalModel( + response: SavedObjectsFindResponse +): SavedObjectsFindResponse { + return { + ...response, + saved_objects: response.saved_objects.map((so) => ({ + ...so, + ...transformSavedObjectToExternalModel(so), + })), + }; +} + +export function transformSavedObjectToExternalModel( + caseSavedObject: SavedObject +): SavedObject { + const connector = transformESConnectorOrUseDefault({ + // if the saved object had an error the attributes field will not exist + connector: caseSavedObject.attributes?.connector, + references: caseSavedObject.references, + referenceName: CONNECTOR_ID_REFERENCE_NAME, + }); + + const externalService = transformESExternalService( + caseSavedObject.attributes?.external_service, + caseSavedObject.references + ); + + return { + ...caseSavedObject, + attributes: { + ...caseSavedObject.attributes, + connector, + external_service: externalService, + }, + }; +} + +function transformESExternalService( + // this type needs to match that of CaseFullExternalService except that it does not include the connector_id, see: x-pack/plugins/cases/common/api/cases/case.ts + // that's why it can be null here + externalService: ExternalServicesWithoutConnectorId | null | undefined, + references: SavedObjectReference[] | undefined +): CaseFullExternalService | null { + const connectorIdRef = findConnectorIdReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, references); + + if (!externalService) { + return null; + } + + return { + ...externalService, + connector_id: connectorIdRef?.id ?? null, + }; +} diff --git a/x-pack/plugins/cases/server/services/cases/types.ts b/x-pack/plugins/cases/server/services/cases/types.ts new file mode 100644 index 0000000000000..55c736b032590 --- /dev/null +++ b/x-pack/plugins/cases/server/services/cases/types.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { CaseAttributes, CaseExternalServiceBasicRt } from '../../../common'; +import { ESCaseConnector } from '..'; + +/** + * This type should only be used within the cases service and its helper functions (e.g. the transforms). + * + * The type represents how the external services portion of the object will be layed out when stored in ES. The external_service will have its + * connector_id field removed and placed within the references field. + */ +export type ExternalServicesWithoutConnectorId = Omit< + rt.TypeOf, + 'connector_id' +>; + +/** + * This type should only be used within the cases service and its helper functions (e.g. the transforms). + * + * The type represents how the Cases object will be layed out in ES. It will not have connector.id or external_service.connector_id. + * Instead those fields will be transformed into the references field. + */ +export type ESCaseAttributes = Omit & { + connector: ESCaseConnector; + external_service: ExternalServicesWithoutConnectorId | null; +}; diff --git a/x-pack/plugins/cases/server/services/configure/index.test.ts b/x-pack/plugins/cases/server/services/configure/index.test.ts new file mode 100644 index 0000000000000..199b541d49f98 --- /dev/null +++ b/x-pack/plugins/cases/server/services/configure/index.test.ts @@ -0,0 +1,722 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CaseConnector, + CasesConfigureAttributes, + CasesConfigurePatch, + CASE_CONFIGURE_SAVED_OBJECT, + ConnectorTypes, + SECURITY_SOLUTION_OWNER, +} from '../../../common'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { + SavedObject, + SavedObjectReference, + SavedObjectsCreateOptions, + SavedObjectsFindResult, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, +} from 'kibana/server'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { loggerMock } from '@kbn/logging/target/mocks'; +import { CaseConfigureService } from '.'; +import { ESCasesConfigureAttributes } from './types'; +import { getNoneCaseConnector, CONNECTOR_ID_REFERENCE_NAME } from '../../common'; +import { createESJiraConnector, createJiraConnector, ESCaseConnectorWithId } from '../test_utils'; + +const basicConfigFields = { + closure_type: 'close-by-pushing' as const, + owner: SECURITY_SOLUTION_OWNER, + created_at: '2020-04-09T09:43:51.778Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2020-04-09T09:43:51.778Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, +}; + +const createConfigUpdateParams = ( + connector?: CaseConnector +): Partial => ({ + connector, +}); + +const createConfigPostParams = (connector: CaseConnector): CasesConfigureAttributes => ({ + ...basicConfigFields, + connector, +}); + +const createUpdateConfigSO = ( + connector?: ESCaseConnectorWithId +): SavedObjectsUpdateResponse => { + const references: SavedObjectReference[] = + connector && connector.id !== 'none' + ? [ + { + id: connector.id, + name: CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ] + : []; + + return { + type: CASE_CONFIGURE_SAVED_OBJECT, + id: '1', + attributes: { + connector: connector + ? { name: connector.name, type: connector.type, fields: connector.fields } + : undefined, + }, + version: '1', + references, + }; +}; + +const createConfigSO = ( + connector?: ESCaseConnectorWithId +): SavedObject => { + const references: SavedObjectReference[] = connector + ? [ + { + id: connector.id, + name: CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ] + : []; + + const formattedConnector = { + type: connector?.type ?? ConnectorTypes.jira, + name: connector?.name ?? ConnectorTypes.jira, + fields: connector?.fields ?? null, + }; + + return { + type: CASE_CONFIGURE_SAVED_OBJECT, + id: '1', + attributes: { + ...basicConfigFields, + // if connector is null we'll default this to an incomplete jira value because the service + // should switch it to a none connector when the id can't be found in the references array + connector: formattedConnector, + }, + references, + }; +}; + +const createConfigSOPromise = ( + connector?: ESCaseConnectorWithId +): Promise> => Promise.resolve(createConfigSO(connector)); + +const createConfigFindSO = ( + connector?: ESCaseConnectorWithId +): SavedObjectsFindResult => ({ + ...createConfigSO(connector), + score: 0, +}); + +const createSOFindResponse = ( + savedObjects: Array> +) => ({ + saved_objects: savedObjects, + total: savedObjects.length, + per_page: savedObjects.length, + page: 1, +}); + +describe('CaseConfigureService', () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + const mockLogger = loggerMock.create(); + + let service: CaseConfigureService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new CaseConfigureService(mockLogger); + }); + + describe('transforms the external model to the Elasticsearch model', () => { + describe('patch', () => { + it('creates the update attributes with the fields that were passed in', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigPostParams(createJiraConnector()), + originalConfiguration: {} as SavedObject, + }); + + const { connector: ignoreConnector, ...restUpdateAttributes } = unsecuredSavedObjectsClient + .update.mock.calls[0][2] as Partial; + + expect(restUpdateAttributes).toMatchInlineSnapshot(` + Object { + "closure_type": "close-by-pushing", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "owner": "securitySolution", + "updated_at": "2020-04-09T09:43:51.778Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('transforms the connector.fields to an array of key/value pairs', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigPostParams(createJiraConnector()), + originalConfiguration: {} as SavedObject, + }); + + const { connector } = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + + expect(connector?.fields).toMatchInlineSnapshot(` + Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ] + `); + }); + + it('preserves the connector fields but does not include the id', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigPostParams(createJiraConnector()), + originalConfiguration: {} as SavedObject, + }); + + const { connector } = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + + expect(connector).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ], + "name": ".jira", + "type": ".jira", + } + `); + expect(connector).not.toHaveProperty('id'); + }); + + it('moves the connector.id to the references', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigPostParams(createJiraConnector()), + originalConfiguration: {} as SavedObject, + }); + + const updateAttributes = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + + expect(updateAttributes.connector).not.toHaveProperty('id'); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('moves the connector.id to the references and includes the existing references', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigPostParams(createJiraConnector()), + originalConfiguration: { + references: [{ id: '123', name: 'awesome', type: 'hello' }], + } as SavedObject, + }); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "123", + "name": "awesome", + "type": "hello", + }, + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('does not remove the connector.id reference when the update attributes do not include it', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: { + references: [ + { id: '123', name: CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ], + } as SavedObject, + }); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "123", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('creates an empty update object and null reference when there is no connector', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: {} as SavedObject, + }); + + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot( + `Object {}` + ); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` + Object { + "references": undefined, + } + `); + }); + + it('creates an update object with the none connector', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(getNoneCaseConnector()), + originalConfiguration: {} as SavedObject, + }); + + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Array [], + "name": "none", + "type": ".none", + }, + } + `); + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toEqual([]); + }); + }); + + describe('post', () => { + it('includes the creation attributes excluding the connector.id field', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.post({ + unsecuredSavedObjectsClient, + attributes: createConfigPostParams(createJiraConnector()), + id: '1', + }); + + const creationAttributes = unsecuredSavedObjectsClient.create.mock + .calls[0][1] as ESCasesConfigureAttributes; + expect(creationAttributes.connector).not.toHaveProperty('id'); + expect(creationAttributes).toMatchInlineSnapshot(` + Object { + "closure_type": "close-by-pushing", + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ], + "name": ".jira", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "owner": "securitySolution", + "updated_at": "2020-04-09T09:43:51.778Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('moves the connector.id to the references', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.post({ + unsecuredSavedObjectsClient, + attributes: createConfigPostParams(createJiraConnector()), + id: '1', + }); + + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ], + } + `); + }); + + it('sets connector.fields to an empty array when it is not included', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.post({ + unsecuredSavedObjectsClient, + attributes: createConfigPostParams(createJiraConnector({ setFieldsToNull: true })), + id: '1', + }); + + const postAttributes = unsecuredSavedObjectsClient.create.mock + .calls[0][1] as CasesConfigureAttributes; + expect(postAttributes.connector).toMatchInlineSnapshot(` + Object { + "fields": Array [], + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('does not create a reference for a none connector', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.post({ + unsecuredSavedObjectsClient, + attributes: createConfigPostParams(getNoneCaseConnector()), + id: '1', + }); + + const creationOptions = unsecuredSavedObjectsClient.create.mock + .calls[0][2] as SavedObjectsCreateOptions; + expect(creationOptions.references).toEqual([]); + }); + }); + }); + + describe('transform the Elasticsearch model to the external model', () => { + describe('patch', () => { + it('returns an object with a none connector and without a reference', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateConfigSO(getNoneCaseConnector())) + ); + + const res = await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: {} as SavedObject, + }); + + expect(res.attributes).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + } + `); + expect(res.references).toMatchInlineSnapshot(`Array []`); + }); + + it('returns an undefined connector if it is not returned by the update', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + const res = await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: {} as SavedObject, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "attributes": Object {}, + } + `); + }); + + it('returns the default none connector when it cannot find the reference', async () => { + const { name, type, fields } = createESJiraConnector(); + const returnValue: SavedObjectsUpdateResponse = { + type: CASE_CONFIGURE_SAVED_OBJECT, + id: '1', + attributes: { + connector: { + name, + type, + fields, + }, + }, + version: '1', + references: undefined, + }; + + unsecuredSavedObjectsClient.update.mockReturnValue(Promise.resolve(returnValue)); + + const res = await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: {} as SavedObject, + }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('returns a jira connector', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateConfigSO(createESJiraConnector())) + ); + + const res = await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: {} as SavedObject, + }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "1", + "name": ".jira", + "type": ".jira", + } + `); + }); + }); + + describe('find', () => { + it('includes the id field in the response', async () => { + const findMockReturn = createSOFindResponse([ + createConfigFindSO(createESJiraConnector()), + createConfigFindSO(), + ]); + unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); + + const res = await service.find({ unsecuredSavedObjectsClient }); + expect(res.saved_objects[0].attributes.connector.id).toMatchInlineSnapshot(`"1"`); + }); + + it('includes the saved object find response fields in the result', async () => { + const findMockReturn = createSOFindResponse([ + createConfigFindSO(createESJiraConnector()), + createConfigFindSO(), + ]); + unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); + + const res = await service.find({ unsecuredSavedObjectsClient }); + const { saved_objects: ignored, ...findResponseFields } = res; + expect(findResponseFields).toMatchInlineSnapshot(` + Object { + "page": 1, + "per_page": 2, + "total": 2, + } + `); + }); + + it('defaults to the none connector when the id cannot be found in the references', async () => { + const findMockReturn = createSOFindResponse([ + createConfigFindSO(createESJiraConnector()), + createConfigFindSO(), + ]); + unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); + + const res = await service.find({ unsecuredSavedObjectsClient }); + expect(res.saved_objects[1].attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + }); + + describe('get', () => { + it('includes the id field in the response', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + createConfigSOPromise(createESJiraConnector()) + ); + const res = await service.get({ unsecuredSavedObjectsClient, configurationId: '1' }); + + expect(res.attributes.connector.id).toMatchInlineSnapshot(`"1"`); + }); + + it('defaults to the none connector when the connector reference cannot be found', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue(createConfigSOPromise()); + const res = await service.get({ unsecuredSavedObjectsClient, configurationId: '1' }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('defaults to the none connector when attributes is undefined', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve(({ + references: [ + { + id: '1', + name: CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ], + } as unknown) as SavedObject) + ); + const res = await service.get({ unsecuredSavedObjectsClient, configurationId: '1' }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index 348bff954b73e..a25818f4ff593 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -5,10 +5,28 @@ * 2.0. */ -import { Logger, SavedObjectsClientContract } from 'kibana/server'; +import { + Logger, + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsUpdateResponse, +} from 'kibana/server'; -import { SavedObjectFindOptionsKueryNode } from '../../common'; -import { ESCasesConfigureAttributes, CASE_CONFIGURE_SAVED_OBJECT } from '../../../common'; +import { SavedObjectFindOptionsKueryNode, CONNECTOR_ID_REFERENCE_NAME } from '../../common'; +import { + CASE_CONFIGURE_SAVED_OBJECT, + CasesConfigureAttributes, + CasesConfigurePatch, +} from '../../../common'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { + transformFieldsToESModel, + transformESConnectorToExternalModel, + transformESConnectorOrUseDefault, +} from '../transform'; +import { ConnectorReferenceHandler } from '../connector_reference_handler'; +import { ESCasesConfigureAttributes } from './types'; interface ClientArgs { unsecuredSavedObjectsClient: SavedObjectsClientContract; @@ -22,13 +40,14 @@ interface FindCaseConfigureArgs extends ClientArgs { } interface PostCaseConfigureArgs extends ClientArgs { - attributes: ESCasesConfigureAttributes; + attributes: CasesConfigureAttributes; id: string; } interface PatchCaseConfigureArgs extends ClientArgs { configurationId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; + originalConfiguration: SavedObject; } export class CaseConfigureService { @@ -44,45 +63,61 @@ export class CaseConfigureService { } } - public async get({ unsecuredSavedObjectsClient, configurationId }: GetCaseConfigureArgs) { + public async get({ + unsecuredSavedObjectsClient, + configurationId, + }: GetCaseConfigureArgs): Promise> { try { this.log.debug(`Attempting to GET case configuration ${configurationId}`); - return await unsecuredSavedObjectsClient.get( + const configuration = await unsecuredSavedObjectsClient.get( CASE_CONFIGURE_SAVED_OBJECT, configurationId ); + + return transformToExternalModel(configuration); } catch (error) { this.log.debug(`Error on GET case configuration ${configurationId}: ${error}`); throw error; } } - public async find({ unsecuredSavedObjectsClient, options }: FindCaseConfigureArgs) { + public async find({ + unsecuredSavedObjectsClient, + options, + }: FindCaseConfigureArgs): Promise> { try { this.log.debug(`Attempting to find all case configuration`); - return await unsecuredSavedObjectsClient.find({ + + const findResp = await unsecuredSavedObjectsClient.find({ ...options, // Get the latest configuration sortField: 'created_at', sortOrder: 'desc', type: CASE_CONFIGURE_SAVED_OBJECT, }); + + return transformFindResponseToExternalModel(findResp); } catch (error) { this.log.debug(`Attempting to find all case configuration`); throw error; } } - public async post({ unsecuredSavedObjectsClient, attributes, id }: PostCaseConfigureArgs) { + public async post({ + unsecuredSavedObjectsClient, + attributes, + id, + }: PostCaseConfigureArgs): Promise> { try { this.log.debug(`Attempting to POST a new case configuration`); - return await unsecuredSavedObjectsClient.create( + const esConfigInfo = transformAttributesToESModel(attributes); + const createdConfig = await unsecuredSavedObjectsClient.create( CASE_CONFIGURE_SAVED_OBJECT, - { - ...attributes, - }, - { id } + esConfigInfo.attributes, + { id, references: esConfigInfo.referenceHandler.build() } ); + + return transformToExternalModel(createdConfig); } catch (error) { this.log.debug(`Error on POST a new case configuration: ${error}`); throw error; @@ -93,19 +128,124 @@ export class CaseConfigureService { unsecuredSavedObjectsClient, configurationId, updatedAttributes, - }: PatchCaseConfigureArgs) { + originalConfiguration, + }: PatchCaseConfigureArgs): Promise> { try { this.log.debug(`Attempting to UPDATE case configuration ${configurationId}`); - return await unsecuredSavedObjectsClient.update( + const esUpdateInfo = transformAttributesToESModel(updatedAttributes); + + const updatedConfiguration = await unsecuredSavedObjectsClient.update( CASE_CONFIGURE_SAVED_OBJECT, configurationId, { - ...updatedAttributes, + ...esUpdateInfo.attributes, + }, + { + references: esUpdateInfo.referenceHandler.build(originalConfiguration.references), } ); + + return transformUpdateResponseToExternalModel(updatedConfiguration); } catch (error) { this.log.debug(`Error on UPDATE case configuration ${configurationId}: ${error}`); throw error; } } } + +function transformUpdateResponseToExternalModel( + updatedConfiguration: SavedObjectsUpdateResponse +): SavedObjectsUpdateResponse { + const { connector, ...restUpdatedAttributes } = updatedConfiguration.attributes ?? {}; + + const transformedConnector = transformESConnectorToExternalModel({ + connector, + references: updatedConfiguration.references, + referenceName: CONNECTOR_ID_REFERENCE_NAME, + }); + + return { + ...updatedConfiguration, + attributes: { + ...restUpdatedAttributes, + // this will avoid setting connector to undefined, it won't include to field at all + ...(transformedConnector && { connector: transformedConnector }), + }, + }; +} + +function transformToExternalModel( + configuration: SavedObject +): SavedObject { + const connector = transformESConnectorOrUseDefault({ + // if the saved object had an error the attributes field will not exist + connector: configuration.attributes?.connector, + references: configuration.references, + referenceName: CONNECTOR_ID_REFERENCE_NAME, + }); + + return { + ...configuration, + attributes: { + ...configuration.attributes, + connector, + }, + }; +} + +function transformFindResponseToExternalModel( + configurations: SavedObjectsFindResponse +): SavedObjectsFindResponse { + return { + ...configurations, + saved_objects: configurations.saved_objects.map((so) => ({ + ...so, + ...transformToExternalModel(so), + })), + }; +} + +function transformAttributesToESModel( + configuration: CasesConfigureAttributes +): { + attributes: ESCasesConfigureAttributes; + referenceHandler: ConnectorReferenceHandler; +}; +function transformAttributesToESModel( + configuration: Partial +): { + attributes: Partial; + referenceHandler: ConnectorReferenceHandler; +}; +function transformAttributesToESModel( + configuration: Partial +): { + attributes: Partial; + referenceHandler: ConnectorReferenceHandler; +} { + const { connector, ...restWithoutConnector } = configuration; + + const transformedConnector = { + ...(connector && { + connector: { + name: connector.name, + type: connector.type, + fields: transformFieldsToESModel(connector), + }, + }), + }; + + return { + attributes: { + ...restWithoutConnector, + ...transformedConnector, + }, + referenceHandler: buildReferenceHandler(connector?.id), + }; +} + +function buildReferenceHandler(id?: string): ConnectorReferenceHandler { + return new ConnectorReferenceHandler([ + { id, name: CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ]); +} diff --git a/x-pack/plugins/cases/server/services/configure/types.ts b/x-pack/plugins/cases/server/services/configure/types.ts new file mode 100644 index 0000000000000..f52e05a2ff9b5 --- /dev/null +++ b/x-pack/plugins/cases/server/services/configure/types.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CasesConfigureAttributes } from '../../../common'; +import { ESCaseConnector } from '..'; + +/** + * This type should only be used within the configure service. It represents how the configure saved object will be layed + * out in ES. + */ +export type ESCasesConfigureAttributes = Omit & { + connector: ESCaseConnector; +}; diff --git a/x-pack/plugins/cases/server/services/connector_reference_handler.test.ts b/x-pack/plugins/cases/server/services/connector_reference_handler.test.ts new file mode 100644 index 0000000000000..4c42332d10627 --- /dev/null +++ b/x-pack/plugins/cases/server/services/connector_reference_handler.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { noneConnectorId } from '../../common'; +import { ConnectorReferenceHandler } from './connector_reference_handler'; + +describe('ConnectorReferenceHandler', () => { + describe('merge', () => { + it('overwrites the original reference with the new one', () => { + const handler = new ConnectorReferenceHandler([{ id: 'hello2', type: '1', name: 'a' }]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot(` + Array [ + Object { + "id": "hello2", + "name": "a", + "type": "1", + }, + ] + `); + }); + + it('returns the original references if the new references is an empty array', () => { + const handler = new ConnectorReferenceHandler([]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot(` + Array [ + Object { + "id": "hello", + "name": "a", + "type": "1", + }, + ] + `); + }); + + it('returns undefined when there are no original references and no new ones', () => { + const handler = new ConnectorReferenceHandler([]); + + expect(handler.build()).toBeUndefined(); + }); + + it('returns an empty array when there is an empty array of original references and no new ones', () => { + const handler = new ConnectorReferenceHandler([]); + + expect(handler.build([])).toMatchInlineSnapshot(`Array []`); + }); + + it('removes a reference when the id field is null', () => { + const handler = new ConnectorReferenceHandler([{ id: null, name: 'a', type: '1' }]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot( + `Array []` + ); + }); + + it('removes a reference when the id field is the none connector', () => { + const handler = new ConnectorReferenceHandler([ + { id: noneConnectorId, name: 'a', type: '1' }, + ]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot( + `Array []` + ); + }); + + it('does not remove a reference when the id field is undefined', () => { + const handler = new ConnectorReferenceHandler([{ id: undefined, name: 'a', type: '1' }]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot(` + Array [ + Object { + "id": "hello", + "name": "a", + "type": "1", + }, + ] + `); + }); + + it('adds a new reference to existing ones', () => { + const handler = new ConnectorReferenceHandler([{ id: 'awesome', type: '2', name: 'b' }]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot(` + Array [ + Object { + "id": "hello", + "name": "a", + "type": "1", + }, + Object { + "id": "awesome", + "name": "b", + "type": "2", + }, + ] + `); + }); + + it('adds new references to an undefined original reference array', () => { + const handler = new ConnectorReferenceHandler([ + { id: 'awesome', type: '2', name: 'a' }, + { id: 'awesome', type: '2', name: 'b' }, + ]); + + expect(handler.build()).toMatchInlineSnapshot(` + Array [ + Object { + "id": "awesome", + "name": "a", + "type": "2", + }, + Object { + "id": "awesome", + "name": "b", + "type": "2", + }, + ] + `); + }); + + it('adds new references to an empty original reference array', () => { + const handler = new ConnectorReferenceHandler([ + { id: 'awesome', type: '2', name: 'a' }, + { id: 'awesome', type: '2', name: 'b' }, + ]); + + expect(handler.build()).toMatchInlineSnapshot(` + Array [ + Object { + "id": "awesome", + "name": "a", + "type": "2", + }, + Object { + "id": "awesome", + "name": "b", + "type": "2", + }, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/connector_reference_handler.ts b/x-pack/plugins/cases/server/services/connector_reference_handler.ts new file mode 100644 index 0000000000000..81e1541366ab5 --- /dev/null +++ b/x-pack/plugins/cases/server/services/connector_reference_handler.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectReference } from 'kibana/server'; +import { noneConnectorId } from '../../common'; + +interface Reference { + soReference?: SavedObjectReference; + name: string; +} + +export class ConnectorReferenceHandler { + private newReferences: Reference[] = []; + + constructor(references: Array<{ id?: string | null; name: string; type: string }>) { + for (const { id, name, type } of references) { + // When id is null, or the none connector we'll try to remove the reference if it exists + // When id is undefined it means that we're doing a patch request and this particular field shouldn't be updated + // so we'll ignore it. If it was already in the reference array then it'll stay there when we merge them together below + if (id === null || id === noneConnectorId) { + this.newReferences.push({ name }); + } else if (id) { + this.newReferences.push({ soReference: { id, name, type }, name }); + } + } + } + + /** + * Merges the references passed to the constructor into the original references passed into this function + * + * @param originalReferences existing saved object references + * @returns a merged reference list or undefined when there are no new or existing references + */ + public build(originalReferences?: SavedObjectReference[]): SavedObjectReference[] | undefined { + if (this.newReferences.length <= 0) { + return originalReferences; + } + + const refMap = new Map( + originalReferences?.map((ref) => [ref.name, ref]) + ); + + for (const newRef of this.newReferences) { + if (newRef.soReference) { + refMap.set(newRef.name, newRef.soReference); + } else { + refMap.delete(newRef.name); + } + } + + return Array.from(refMap.values()); + } +} diff --git a/x-pack/plugins/cases/server/services/index.ts b/x-pack/plugins/cases/server/services/index.ts index 09895d9392441..f910099c0cc20 100644 --- a/x-pack/plugins/cases/server/services/index.ts +++ b/x-pack/plugins/cases/server/services/index.ts @@ -6,6 +6,7 @@ */ import { SavedObjectsClientContract } from 'kibana/server'; +import { ConnectorTypes } from '../../common'; export { CasesService } from './cases'; export { CaseConfigureService } from './configure'; @@ -17,3 +18,14 @@ export { AttachmentService } from './attachments'; export interface ClientArgs { unsecuredSavedObjectsClient: SavedObjectsClientContract; } + +export type ESConnectorFields = Array<{ + key: string; + value: unknown; +}>; + +export interface ESCaseConnector { + name: string; + type: ConnectorTypes; + fields: ESConnectorFields | null; +} diff --git a/x-pack/plugins/cases/server/services/test_utils.ts b/x-pack/plugins/cases/server/services/test_utils.ts new file mode 100644 index 0000000000000..b712ea07f9c71 --- /dev/null +++ b/x-pack/plugins/cases/server/services/test_utils.ts @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObject, SavedObjectReference } from 'kibana/server'; +import { ESConnectorFields } from '.'; +import { CONNECTOR_ID_REFERENCE_NAME, PUSH_CONNECTOR_ID_REFERENCE_NAME } from '../common'; +import { + CaseConnector, + CaseFullExternalService, + CaseStatuses, + CaseType, + CASE_SAVED_OBJECT, + ConnectorTypes, + noneConnectorId, + SECURITY_SOLUTION_OWNER, +} from '../../common'; +import { ESCaseAttributes, ExternalServicesWithoutConnectorId } from './cases/types'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../actions/server'; + +/** + * This is only a utility interface to help with constructing test cases. After the migration, the ES format will no longer + * have the id field. Instead it will be moved to the references array. + */ +export interface ESCaseConnectorWithId { + id: string; + name: string; + type: ConnectorTypes; + fields: ESConnectorFields | null; +} + +/** + * This file contains utility functions to aid unit test development + */ + +/** + * Create an Elasticsearch jira connector. + * + * @param overrides fields used to override the default jira connector + * @returns a jira Elasticsearch connector (it has key value pairs for the fields) by default + */ +export const createESJiraConnector = ( + overrides?: Partial +): ESCaseConnectorWithId => { + return { + id: '1', + name: ConnectorTypes.jira, + fields: [ + { key: 'issueType', value: 'bug' }, + { key: 'priority', value: 'high' }, + { key: 'parent', value: '2' }, + ], + type: ConnectorTypes.jira, + ...(overrides && { ...overrides }), + }; +}; + +/** + * Creates a jira CaseConnector (has the actual fields defined in the object instead of key value paris) + * @param setFieldsToNull a flag that controls setting the fields property to null + * @returns a jira connector + */ +export const createJiraConnector = ({ + setFieldsToNull, +}: { setFieldsToNull?: boolean } = {}): CaseConnector => { + return { + id: '1', + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + fields: setFieldsToNull + ? null + : { + issueType: 'bug', + priority: 'high', + parent: '2', + }, + }; +}; + +export const createExternalService = ( + overrides?: Partial +): CaseFullExternalService => ({ + connector_id: '100', + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + ...(overrides && { ...overrides }), +}); + +export const basicCaseFields = { + closed_at: null, + closed_by: null, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + status: CaseStatuses.open, + tags: ['defacement'], + type: CaseType.individual, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + settings: { + syncAlerts: true, + }, + owner: SECURITY_SOLUTION_OWNER, +}; + +export const createCaseSavedObjectResponse = ({ + connector, + externalService, +}: { + connector?: ESCaseConnectorWithId; + externalService?: CaseFullExternalService; +} = {}): SavedObject => { + const references: SavedObjectReference[] = createSavedObjectReferences({ + connector, + externalService, + }); + + const formattedConnector = { + type: connector?.type ?? ConnectorTypes.jira, + name: connector?.name ?? ConnectorTypes.jira, + fields: connector?.fields ?? null, + }; + + let restExternalService: ExternalServicesWithoutConnectorId | null = null; + if (externalService !== null) { + const { connector_id: ignored, ...rest } = externalService ?? { + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }; + restExternalService = rest; + } + + return { + type: CASE_SAVED_OBJECT, + id: '1', + attributes: { + ...basicCaseFields, + // if connector is null we'll default this to an incomplete jira value because the service + // should switch it to a none connector when the id can't be found in the references array + connector: formattedConnector, + external_service: restExternalService, + }, + references, + }; +}; + +export const createSavedObjectReferences = ({ + connector, + externalService, +}: { + connector?: ESCaseConnectorWithId; + externalService?: CaseFullExternalService; +} = {}): SavedObjectReference[] => [ + ...(connector && connector.id !== noneConnectorId + ? [ + { + id: connector.id, + name: CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ] + : []), + ...(externalService && externalService.connector_id + ? [ + { + id: externalService.connector_id, + name: PUSH_CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ] + : []), +]; diff --git a/x-pack/plugins/cases/server/services/transform.test.ts b/x-pack/plugins/cases/server/services/transform.test.ts new file mode 100644 index 0000000000000..b4346595e4998 --- /dev/null +++ b/x-pack/plugins/cases/server/services/transform.test.ts @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ACTION_SAVED_OBJECT_TYPE } from '../../../actions/server'; +import { ConnectorTypes } from '../../common'; +import { createESJiraConnector, createJiraConnector } from './test_utils'; +import { + findConnectorIdReference, + transformESConnectorOrUseDefault, + transformESConnectorToExternalModel, + transformFieldsToESModel, +} from './transform'; + +describe('service transform helpers', () => { + describe('findConnectorIdReference', () => { + it('finds the reference when it exists', () => { + expect( + findConnectorIdReference('a', [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }]) + ).toBeDefined(); + }); + + it('does not find the reference when the name is different', () => { + expect( + findConnectorIdReference('a', [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'b' }]) + ).toBeUndefined(); + }); + + it('does not find the reference when references is empty', () => { + expect(findConnectorIdReference('a', [])).toBeUndefined(); + }); + + it('does not find the reference when references is undefined', () => { + expect(findConnectorIdReference('a', undefined)).toBeUndefined(); + }); + + it('does not find the reference when the type is different', () => { + expect( + findConnectorIdReference('a', [{ id: 'hello', type: 'yo', name: 'a' }]) + ).toBeUndefined(); + }); + }); + + describe('transformESConnectorToExternalModel', () => { + it('returns undefined when the connector is undefined', () => { + expect(transformESConnectorToExternalModel({ referenceName: 'a' })).toBeUndefined(); + }); + + it('returns the default connector when it cannot find the reference', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector(), + referenceName: 'a', + }) + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('converts the connector.fields to an object', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector(), + references: [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }], + referenceName: 'a', + })?.fields + ).toMatchInlineSnapshot(` + Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + } + `); + }); + + it('returns the full jira connector', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector(), + references: [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }], + referenceName: 'a', + }) + ).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "hello", + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('sets fields to null if it is an empty array', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector({ fields: [] }), + references: [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }], + referenceName: 'a', + }) + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "hello", + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('sets fields to null if it is null', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector({ fields: null }), + references: [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }], + referenceName: 'a', + }) + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "hello", + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('sets fields to null if it is undefined', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector({ fields: undefined }), + references: [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }], + referenceName: 'a', + }) + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "hello", + "name": ".jira", + "type": ".jira", + } + `); + }); + }); + + describe('transformESConnectorOrUseDefault', () => { + it('returns the default connector when the connector is undefined', () => { + expect(transformESConnectorOrUseDefault({ referenceName: 'a' })).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + }); + + describe('transformFieldsToESModel', () => { + it('returns an empty array when fields is null', () => { + expect(transformFieldsToESModel(createJiraConnector({ setFieldsToNull: true })).length).toBe( + 0 + ); + }); + + it('returns an empty array when fields is an empty object', () => { + expect( + transformFieldsToESModel({ + id: '1', + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + fields: {} as { + issueType: string; + priority: string; + parent: string; + }, + }).length + ).toBe(0); + }); + + it('returns an array with the key/value pairs', () => { + expect(transformFieldsToESModel(createJiraConnector())).toMatchInlineSnapshot(` + Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/transform.ts b/x-pack/plugins/cases/server/services/transform.ts new file mode 100644 index 0000000000000..39351d3a4b50a --- /dev/null +++ b/x-pack/plugins/cases/server/services/transform.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectReference } from 'kibana/server'; +import { CaseConnector, ConnectorTypeFields } from '../../common'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../actions/server'; +import { getNoneCaseConnector } from '../common'; +import { ESCaseConnector, ESConnectorFields } from '.'; + +export function findConnectorIdReference( + name: string, + references?: SavedObjectReference[] +): SavedObjectReference | undefined { + return references?.find((ref) => ref.type === ACTION_SAVED_OBJECT_TYPE && ref.name === name); +} + +export function transformESConnectorToExternalModel({ + connector, + references, + referenceName, +}: { + connector?: ESCaseConnector; + references?: SavedObjectReference[]; + referenceName: string; +}): CaseConnector | undefined { + const connectorIdRef = findConnectorIdReference(referenceName, references); + return transformConnectorFieldsToExternalModel(connector, connectorIdRef?.id); +} + +function transformConnectorFieldsToExternalModel( + connector?: ESCaseConnector, + connectorId?: string +): CaseConnector | undefined { + if (!connector) { + return; + } + + // if the connector is valid, but we can't find it's ID in the reference, then it must be malformed + // or it was a none connector which doesn't have a reference (a none connector doesn't point to any actual connector + // saved object) + if (!connectorId) { + return getNoneCaseConnector(); + } + + const connectorTypeField = { + type: connector.type, + fields: + connector.fields != null && connector.fields.length > 0 + ? connector.fields.reduce( + (fields, { key, value }) => ({ + ...fields, + [key]: value, + }), + {} + ) + : null, + } as ConnectorTypeFields; + + return { + id: connectorId, + name: connector.name, + ...connectorTypeField, + }; +} + +export function transformESConnectorOrUseDefault({ + connector, + references, + referenceName, +}: { + connector?: ESCaseConnector; + references?: SavedObjectReference[]; + referenceName: string; +}): CaseConnector { + return ( + transformESConnectorToExternalModel({ connector, references, referenceName }) ?? + getNoneCaseConnector() + ); +} + +export function transformFieldsToESModel(connector: CaseConnector): ESConnectorFields { + if (!connector.fields) { + return []; + } + + return Object.entries(connector.fields).reduce( + (acc, [key, value]) => [ + ...acc, + { + key, + value, + }, + ], + [] + ); +} diff --git a/x-pack/plugins/cases/server/services/user_actions/helpers.ts b/x-pack/plugins/cases/server/services/user_actions/helpers.ts index 4d0f899d40785..223e731aa8d9b 100644 --- a/x-pack/plugins/cases/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/cases/server/services/user_actions/helpers.ts @@ -13,18 +13,16 @@ import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, CaseUserActionAttributes, - ESCaseAttributes, OWNER_FIELD, SUB_CASE_SAVED_OBJECT, SubCaseAttributes, User, UserAction, UserActionField, - UserActionFieldType, + CaseAttributes, } from '../../../common'; import { isTwoArraysDifference } from '../../client/utils'; import { UserActionItem } from '.'; -import { transformESConnectorToCaseConnector } from '../../common'; export const transformNewUserAction = ({ actionField, @@ -173,17 +171,12 @@ interface CaseSubIDs { } type GetCaseAndSubID = (so: SavedObjectsUpdateResponse) => CaseSubIDs; -type GetField = ( - attributes: Pick, 'attributes'>, - field: UserActionFieldType -) => unknown; /** * Abstraction functions to retrieve a given field and the caseId and subCaseId depending on * whether we're interacting with a case or a sub case. */ interface Getters { - getField: GetField; getCaseAndSubID: GetCaseAndSubID; } @@ -209,7 +202,7 @@ const buildGenericCaseUserActions = ({ allowedFields: UserActionField; getters: Getters; }): UserActionItem[] => { - const { getCaseAndSubID, getField } = getters; + const { getCaseAndSubID } = getters; return updatedCases.reduce((acc, updatedItem) => { const { caseId, subCaseId } = getCaseAndSubID(updatedItem); // regardless of whether we're looking at a sub case or case, the id field will always be used to match between @@ -220,8 +213,8 @@ const buildGenericCaseUserActions = ({ const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; updatedFields.forEach((field) => { if (allowedFields.includes(field)) { - const origValue = getField(originalItem, field); - const updatedValue = getField(updatedItem, field); + const origValue = get(originalItem, ['attributes', field]); + const updatedValue = get(updatedItem, ['attributes', field]); if (isString(origValue) && isString(updatedValue) && origValue !== updatedValue) { userActions = [ @@ -308,18 +301,12 @@ export const buildSubCaseUserActions = (args: { originalSubCases: Array>; updatedSubCases: Array>; }): UserActionItem[] => { - const getField = ( - so: Pick, 'attributes'>, - field: UserActionFieldType - ) => get(so, ['attributes', field]); - const getCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { const caseId = so.references?.find((ref) => ref.type === CASE_SAVED_OBJECT)?.id ?? ''; return { caseId, subCaseId: so.id }; }; const getters: Getters = { - getField, getCaseAndSubID, }; @@ -339,24 +326,14 @@ export const buildSubCaseUserActions = (args: { export const buildCaseUserActions = (args: { actionDate: string; actionBy: User; - originalCases: Array>; - updatedCases: Array>; + originalCases: Array>; + updatedCases: Array>; }): UserActionItem[] => { - const getField = ( - so: Pick, 'attributes'>, - field: UserActionFieldType - ) => { - return field === 'connector' && so.attributes.connector - ? transformESConnectorToCaseConnector(so.attributes.connector) - : get(so, ['attributes', field]); - }; - const caseGetIds: GetCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { return { caseId: so.id }; }; const getters: Getters = { - getField, getCaseAndSubID: caseGetIds, }; diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts index 5fdf4680f5ca8..b702448165554 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.ts @@ -55,6 +55,7 @@ export class CaseUserActionService { public async bulkCreate({ unsecuredSavedObjectsClient, actions }: PostCaseUserActionArgs) { try { this.log.debug(`Attempting to POST a new case user action`); + return await unsecuredSavedObjectsClient.bulkCreate( actions.map((action) => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) ); diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index e7c6464bc1546..6a3d812b1bf5b 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -469,6 +469,7 @@ export type TimelineExpandedEventType = params?: { eventId: string; indexName: string; + refetch?: () => void; }; } | EmptyObject; diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts index dda86d2717386..c1cfe321777bb 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_details.spec.ts @@ -47,7 +47,8 @@ describe('Alert details with unmapped fields', () => { const length = elements.length; cy.wrap(elements) .eq(length - expectedUnmappedField.line) - .should('have.text', expectedUnmappedField.text); + .invoke('text') + .should('include', expectedUnmappedField.text); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts index 4c4810c5daf31..8e82394da1db8 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/cti_enrichments.spec.ts @@ -89,7 +89,8 @@ describe('CTI Enrichment', () => { expectedEnrichment.forEach((enrichment) => { cy.wrap(elements) .eq(length - enrichment.line) - .should('have.text', enrichment.text); + .invoke('text') + .should('include', enrichment.text); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts index 10ed18eb91d0c..7d833b134ddd7 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/custom_query_rule.spec.ts @@ -238,10 +238,9 @@ describe('Custom detection rules deletion and edition', () => { goToManageAlertsDetectionRules(); waitForAlertsIndexToBeCreated(); createCustomRuleActivated(getNewRule(), 'rule1'); - createCustomRuleActivated(getNewRule(), 'rule2'); - createCustomRuleActivated(getNewOverrideRule(), 'rule3'); - createCustomRuleActivated(getExistingRule(), 'rule4'); + createCustomRuleActivated(getNewOverrideRule(), 'rule2'); + createCustomRuleActivated(getExistingRule(), 'rule3'); reload(); }); @@ -295,7 +294,7 @@ describe('Custom detection rules deletion and edition', () => { }); cy.get(SHOWING_RULES_TEXT).should( 'have.text', - `Showing ${expectedNumberOfRulesAfterDeletion} rules` + `Showing ${expectedNumberOfRulesAfterDeletion} rule` ); cy.get(CUSTOM_RULES_BTN).should( 'have.text', diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index 30fd5bd801182..e0430fb402769 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -5,7 +5,7 @@ * 2.0. */ -export const ADD_EXCEPTION_BTN = '[data-test-subj="addExceptionButton"]'; +export const ADD_EXCEPTION_BTN = '[data-test-subj="add-exception-menu-item"]'; export const ALERTS = '[data-test-subj="events-viewer-panel"] [data-test-subj="event"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts index 2449a90f5328c..5307ebc267efc 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_details.ts @@ -9,9 +9,11 @@ export const ALERT_FLYOUT = '[data-test-subj="timeline:details-panel:flyout"]'; export const CELL_TEXT = '.euiText'; +export const JSON_VIEW_WRAPPER = '[data-test-subj="jsonViewWrapper"]'; + export const JSON_CONTENT = '[data-test-subj="jsonView"]'; -export const JSON_LINES = '.ace_line'; +export const JSON_LINES = '.euiCodeBlock__line'; export const JSON_VIEW_TAB = '[data-test-subj="jsonViewTab"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts index f7f7f72e83d41..091a483399ada 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_details.ts @@ -7,7 +7,7 @@ import { ENRICHMENT_COUNT_NOTIFICATION, - JSON_CONTENT, + JSON_VIEW_WRAPPER, JSON_VIEW_TAB, TABLE_TAB, } from '../screens/alerts_details'; @@ -25,7 +25,7 @@ export const openThreatIndicatorDetails = () => { }; export const scrollJsonViewToBottom = () => { - cy.get(JSON_CONTENT).click({ force: true }); - cy.get(JSON_CONTENT).type('{pagedown}{pagedown}{pagedown}'); - cy.get(JSON_CONTENT).should('be.visible'); + cy.get(JSON_VIEW_WRAPPER).click({ force: true }); + cy.get(JSON_VIEW_WRAPPER).type('{pagedown}{pagedown}{pagedown}'); + cy.get(JSON_VIEW_WRAPPER).should('be.visible'); }; 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 10a9e27fee1cf..fdb12170309c7 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 @@ -7,8 +7,6 @@ import React, { useCallback, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; -import { SearchResponse } from 'elasticsearch'; -import { isEmpty } from 'lodash'; import { getCaseDetailsUrl, @@ -18,18 +16,17 @@ import { getRuleDetailsUrl, useFormatUrl, } from '../../../common/components/link_to'; -import { Ecs } from '../../../../common/ecs'; import { Case, CaseViewRefreshPropInterface } from '../../../../../cases/common'; import { TimelineId } from '../../../../common/types/timeline'; import { SecurityPageName } from '../../../app/types'; -import { KibanaServices, useKibana } from '../../../common/lib/kibana'; -import { APP_ID, DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../common/constants'; +import { useKibana } from '../../../common/lib/kibana'; +import { APP_ID } from '../../../../common/constants'; import { timelineActions } from '../../../timelines/store/timeline'; import { useSourcererScope } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { DetailsPanel } from '../../../timelines/components/side_panel'; import { InvestigateInTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action'; -import { buildAlertsQuery, formatAlertToEcsSignal, useFetchAlertData } from './helpers'; +import { useFetchAlertData } from './helpers'; import { SEND_ALERT_TO_TIMELINE } from './translations'; import { useInsertTimeline } from '../use_insert_timeline'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; @@ -70,39 +67,12 @@ const TimelineDetailsPanel = () => { }; const InvestigateInTimelineActionComponent = (alertIds: string[]) => { - const fetchEcsAlertsData = async (fetchAlertIds?: string[]): Promise => { - if (isEmpty(fetchAlertIds)) { - return []; - } - const alertResponse = await KibanaServices.get().http.fetch< - SearchResponse<{ '@timestamp': string; [key: string]: unknown }> - >(DETECTION_ENGINE_QUERY_SIGNALS_URL, { - method: 'POST', - body: JSON.stringify(buildAlertsQuery(fetchAlertIds ?? [])), - }); - return ( - alertResponse?.hits.hits.reduce( - (acc, { _id, _index, _source }) => [ - ...acc, - { - ...formatAlertToEcsSignal(_source as {}), - _id, - _index, - timestamp: _source['@timestamp'], - }, - ], - [] - ) ?? [] - ); - }; - return ( ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap index 0412b3074e3f1..d4e839c4bef77 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/json_view.test.tsx.snap @@ -2,54 +2,50 @@ exports[`JSON View rendering should match snapshot 1`] = ` - + { + "_id": "pEMaMmkBUV60JmNWmWVi", + "_index": "filebeat-8.0.0-2019.02.19-000001", + "_score": 1, + "_type": "_doc", + "@timestamp": "2019-02-28T16:50:54.621Z", + "agent": { + "ephemeral_id": "9d391ef2-a734-4787-8891-67031178c641", + "hostname": "siem-kibana", + "id": "5de03d5f-52f3-482e-91d4-853c7de073c3", + "type": "filebeat", + "version": "8.0.0" }, - \\"cloud\\": { - \\"availability_zone\\": \\"projects/189716325846/zones/us-east1-b\\", - \\"instance\\": { - \\"id\\": \\"5412578377715150143\\", - \\"name\\": \\"siem-kibana\\" + "cloud": { + "availability_zone": "projects/189716325846/zones/us-east1-b", + "instance": { + "id": "5412578377715150143", + "name": "siem-kibana" }, - \\"machine\\": { - \\"type\\": \\"projects/189716325846/machineTypes/n1-standard-1\\" + "machine": { + "type": "projects/189716325846/machineTypes/n1-standard-1" }, - \\"project\\": { - \\"id\\": \\"elastic-beats\\" + "project": { + "id": "elastic-beats" }, - \\"provider\\": \\"gce\\" + "provider": "gce" }, - \\"destination\\": { - \\"bytes\\": 584, - \\"ip\\": \\"10.47.8.200\\", - \\"packets\\": 4, - \\"port\\": 902 + "destination": { + "bytes": 584, + "ip": "10.47.8.200", + "packets": 4, + "port": 902 }, - \\"event\\": { - \\"kind\\": \\"event\\" + "event": { + "kind": "event" } -}" - width="100%" - /> +} + `; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 1d639eb9497fc..3b5519825f996 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -245,7 +245,7 @@ const EventDetailsComponent: React.FC = ({ content: ( <> - + diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx index c9ca93582cd9a..0614f131bcd10 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/json_view.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiCodeEditor } from '@elastic/eui'; +import { EuiCodeBlock } from '@elastic/eui'; import { set } from '@elastic/safer-lodash-set/fp'; import React, { useMemo } from 'react'; import styled from 'styled-components'; @@ -23,8 +23,6 @@ const EuiCodeEditorContainer = styled.div` } `; -const EDITOR_SET_OPTIONS = { fontSize: '12px' }; - export const JsonView = React.memo(({ data }) => { const value = useMemo( () => @@ -38,15 +36,15 @@ export const JsonView = React.memo(({ data }) => { return ( - + > + {value} + ); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_endpoint_exception.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_endpoint_exception.tsx new file mode 100644 index 0000000000000..23709269a4c13 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_endpoint_exception.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiContextMenuItem, EuiText } from '@elastic/eui'; +import React from 'react'; +import * as i18n from '../translations'; + +interface AddEndpointExceptionProps { + onClick: () => void; + disabled?: boolean; +} + +const AddEndpointExceptionComponent: React.FC = ({ + onClick, + disabled, +}) => { + return ( + + {i18n.ACTION_ADD_ENDPOINT_EXCEPTION} + + ); +}; + +export const AddEndpointException = React.memo(AddEndpointExceptionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_event_filter.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_event_filter.tsx new file mode 100644 index 0000000000000..1104b3eb83081 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_event_filter.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiContextMenuItem, EuiText } from '@elastic/eui'; +import React from 'react'; +import * as i18n from '../translations'; + +interface AddEventFilterProps { + onClick: () => void; + disabled?: boolean; +} + +const AddEventFilterComponent: React.FC = ({ onClick, disabled }) => { + return ( + + + {i18n.ACTION_ADD_EVENT_FILTER} + + + ); +}; + +export const AddEventFilter = React.memo(AddEventFilterComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_exception.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_exception.tsx new file mode 100644 index 0000000000000..030f67c9e708c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/add_exception.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiContextMenuItem, EuiText } from '@elastic/eui'; +import React from 'react'; +import * as i18n from '../translations'; + +interface AddExceptionProps { + disabled?: boolean; + onClick: () => void; +} + +const AddExceptionComponent: React.FC = ({ disabled, onClick }) => { + return ( + + + {i18n.ACTION_ADD_EXCEPTION} + + + ); +}; + +export const AddException = React.memo(AddExceptionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index b1881d29ec10d..3a9a4e875369e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -6,53 +6,33 @@ */ import React, { useCallback, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { - EuiButtonIcon, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiPopover, - EuiText, - EuiToolTip, -} from '@elastic/eui'; + +import { EuiButtonIcon, EuiContextMenu, EuiPopover, EuiToolTip } from '@elastic/eui'; import styled from 'styled-components'; -import { getOr } from 'lodash/fp'; import { indexOf } from 'lodash'; -import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; +import { get, getOr } from 'lodash/fp'; import { buildGetAlertByIdQuery } from '../../../../common/components/exceptions/helpers'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { TimelineId } from '../../../../../common/types/timeline'; -import { - DEFAULT_INDEX_PATTERN, - DEFAULT_INDEX_PATTERN_EXPERIMENTAL, -} from '../../../../../common/constants'; -import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { timelineActions } from '../../../../timelines/store/timeline'; import { EventsTdContent } from '../../../../timelines/components/timeline/styles'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../../../timelines/components/timeline/helpers'; -import { FILTER_OPEN, FILTER_CLOSED, FILTER_IN_PROGRESS } from '../alerts_filter_group'; -import { updateAlertStatusAction } from '../actions'; -import { SetEventsDeletedProps, SetEventsLoadingProps } from '../types'; import { Ecs } from '../../../../../common/ecs'; import { AddExceptionModal, AddExceptionModalProps, } from '../../../../common/components/exceptions/add_exception_modal'; -import * as i18nCommon from '../../../../common/translations'; import * as i18n from '../translations'; -import { - useStateToaster, - displaySuccessToast, - displayErrorToast, -} from '../../../../common/components/toasters'; import { inputsModel } from '../../../../common/store'; -import { useUserData } from '../../user_info'; import { AlertData, EcsHit } from '../../../../common/components/exceptions/types'; import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query'; import { useSignalIndex } from '../../../containers/detection_engine/alerts/use_signal_index'; import { EventFiltersModal } from '../../../../management/pages/event_filters/view/components/modal'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useAlertsActions } from './use_alerts_actions'; +import { useExceptionModal } from './use_add_exception_modal'; +import { useExceptionActions } from './use_add_exception_actions'; +import { useEventFilterModal } from './use_event_filter_modal'; +import { useEventFilterAction } from './use_event_filter_action'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; interface AlertContextMenuProps { ariaLabel?: string; @@ -71,56 +51,14 @@ const AlertContextMenuComponent: React.FC = ({ onRuleChange, timelineId, }) => { - const dispatch = useDispatch(); - const [, dispatchToaster] = useStateToaster(); const [isPopoverOpen, setPopover] = useState(false); - const eventId = ecsRowData._id; - const ruleId = useMemo( - (): string | null => - (ecsRowData.signal?.rule && ecsRowData.signal.rule.id && ecsRowData.signal.rule.id[0]) ?? - null, - [ecsRowData] - ); - const ruleName = useMemo( - (): string => - (ecsRowData.signal?.rule && ecsRowData.signal.rule.name && ecsRowData.signal.rule.name[0]) ?? - '', - [ecsRowData] - ); - - // TODO: Steph/ueba remove when past experimental - const uebaEnabled = useIsExperimentalFeatureEnabled('uebaEnabled'); - const isEvent = useMemo(() => indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]); - const ruleIndices = useMemo((): string[] => { - if ( - ecsRowData.signal?.rule && - ecsRowData.signal.rule.index && - ecsRowData.signal.rule.index.length > 0 - ) { - return ecsRowData.signal.rule.index; - } else { - return uebaEnabled - ? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL] - : DEFAULT_INDEX_PATTERN; - } - }, [ecsRowData.signal?.rule, uebaEnabled]); - - const { addWarning } = useAppToasts(); - const alertStatus = useMemo(() => { - return ecsRowData.signal?.status && (ecsRowData.signal.status[0] as Status); - }, [ecsRowData]); + const ruleId = get(0, ecsRowData?.signal?.rule?.id); + const ruleName = get(0, ecsRowData?.signal?.rule?.name); - const onButtonClick = useCallback(() => { - setPopover(!isPopoverOpen); - }, [isPopoverOpen]); + const alertStatus = get(0, ecsRowData?.signal?.status) as Status; - const closePopover = useCallback((): void => { - setPopover(false); - }, []); - const [exceptionModalType, setOpenAddExceptionModal] = useState(null); - const [isAddEventFilterModalOpen, setIsAddEventFilterModalOpen] = useState(false); - const [{ canUserCRUD, hasIndexWrite, hasIndexMaintenance, hasIndexUpdateDelete }] = useUserData(); + const isEvent = useMemo(() => indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]); const isEndpointAlert = useMemo((): boolean => { if (ecsRowData == null) { @@ -133,188 +71,14 @@ const AlertContextMenuComponent: React.FC = ({ return eventModules.includes('endpoint') && kinds.includes('alert'); }, [ecsRowData]); - const closeAddExceptionModal = useCallback((): void => { - setOpenAddExceptionModal(null); - }, []); + const onButtonClick = useCallback(() => { + setPopover(!isPopoverOpen); + }, [isPopoverOpen]); - const closeAddEventFilterModal = useCallback((): void => { - setIsAddEventFilterModalOpen(false); + const closePopover = useCallback((): void => { + setPopover(false); }, []); - const onAddExceptionCancel = useCallback(() => { - closeAddExceptionModal(); - }, [closeAddExceptionModal]); - - const onAddExceptionConfirm = useCallback( - (didCloseAlert: boolean, didBulkCloseAlert) => { - closeAddExceptionModal(); - if (timelineId !== TimelineId.active || didBulkCloseAlert) { - refetch(); - } - }, - [closeAddExceptionModal, timelineId, refetch] - ); - - const onAlertStatusUpdateSuccess = useCallback( - (updated: number, conflicts: number, newStatus: Status) => { - if (conflicts > 0) { - // Partial failure - addWarning({ - title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), - text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), - }); - } else { - let title: string; - switch (newStatus) { - case 'closed': - title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated); - break; - case 'open': - title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated); - break; - case 'in-progress': - title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated); - } - displaySuccessToast(title, dispatchToaster); - } - }, - [dispatchToaster, addWarning] - ); - - const onAlertStatusUpdateFailure = useCallback( - (newStatus: Status, error: Error) => { - let title: string; - switch (newStatus) { - case 'closed': - title = i18n.CLOSED_ALERT_FAILED_TOAST; - break; - case 'open': - title = i18n.OPENED_ALERT_FAILED_TOAST; - break; - case 'in-progress': - title = i18n.IN_PROGRESS_ALERT_FAILED_TOAST; - } - displayErrorToast(title, [error.message], dispatchToaster); - }, - [dispatchToaster] - ); - - const setEventsLoading = useCallback( - ({ eventIds, isLoading }: SetEventsLoadingProps) => { - dispatch(timelineActions.setEventsLoading({ id: timelineId, eventIds, isLoading })); - }, - [dispatch, timelineId] - ); - - const setEventsDeleted = useCallback( - ({ eventIds, isDeleted }: SetEventsDeletedProps) => { - dispatch(timelineActions.setEventsDeleted({ id: timelineId, eventIds, isDeleted })); - }, - [dispatch, timelineId] - ); - - const openAlertActionOnClick = useCallback(() => { - updateAlertStatusAction({ - alertIds: [eventId], - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted, - setEventsLoading, - selectedStatus: FILTER_OPEN, - }); - closePopover(); - }, [ - closePopover, - eventId, - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted, - setEventsLoading, - ]); - - const openAlertActionComponent = useMemo(() => { - return ( - - {i18n.ACTION_OPEN_ALERT} - - ); - }, [openAlertActionOnClick, hasIndexUpdateDelete, hasIndexMaintenance]); - - const closeAlertActionClick = useCallback(() => { - updateAlertStatusAction({ - alertIds: [eventId], - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted, - setEventsLoading, - selectedStatus: FILTER_CLOSED, - }); - closePopover(); - }, [ - closePopover, - eventId, - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted, - setEventsLoading, - ]); - - const closeAlertActionComponent = useMemo(() => { - return ( - - {i18n.ACTION_CLOSE_ALERT} - - ); - }, [closeAlertActionClick, hasIndexUpdateDelete, hasIndexMaintenance]); - - const inProgressAlertActionClick = useCallback(() => { - updateAlertStatusAction({ - alertIds: [eventId], - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted, - setEventsLoading, - selectedStatus: FILTER_IN_PROGRESS, - }); - closePopover(); - }, [ - closePopover, - eventId, - onAlertStatusUpdateFailure, - onAlertStatusUpdateSuccess, - setEventsDeleted, - setEventsLoading, - ]); - - const inProgressAlertActionComponent = useMemo(() => { - return ( - - {i18n.ACTION_IN_PROGRESS_ALERT} - - ); - }, [canUserCRUD, hasIndexUpdateDelete, inProgressAlertActionClick]); - const button = useMemo(() => { return ( @@ -330,105 +94,61 @@ const AlertContextMenuComponent: React.FC = ({ ); }, [disabled, onButtonClick, ariaLabel]); - const handleAddEndpointExceptionClick = useCallback((): void => { - closePopover(); - setOpenAddExceptionModal('endpoint'); - }, [closePopover]); + const { + exceptionModalType, + onAddExceptionCancel, + onAddExceptionConfirm, + onAddExceptionTypeClick, + ruleIndices, + } = useExceptionModal({ + ruleIndex: ecsRowData?.signal?.rule?.index, + refetch, + timelineId, + }); - const addEndpointExceptionComponent = useMemo(() => { - return ( - - {i18n.ACTION_ADD_ENDPOINT_EXCEPTION} - - ); - }, [canUserCRUD, hasIndexWrite, isEndpointAlert, handleAddEndpointExceptionClick]); + const { + closeAddEventFilterModal, + isAddEventFilterModalOpen, + onAddEventFilterClick, + } = useEventFilterModal(); - const handleAddExceptionClick = useCallback((): void => { - closePopover(); - setOpenAddExceptionModal('detection'); - }, [closePopover]); + const { statusActions } = useAlertsActions({ + alertStatus, + eventId: ecsRowData?._id, + timelineId, + closePopover, + }); - const addExceptionComponent = useMemo(() => { - return ( - - - {i18n.ACTION_ADD_EXCEPTION} - - - ); - }, [handleAddExceptionClick, canUserCRUD, hasIndexWrite]); + const handleOnAddExceptionTypeClick = useCallback( + (type: ExceptionListType) => { + onAddExceptionTypeClick(type); + closePopover(); + }, + [closePopover, onAddExceptionTypeClick] + ); - const handleAddEventFilterClick = useCallback((): void => { + const handleOnAddEventFilterClick = useCallback(() => { + onAddEventFilterClick(); closePopover(); - setIsAddEventFilterModalOpen(true); - }, [closePopover]); - - const addEventFilterComponent = useMemo( - () => ( - - - {i18n.ACTION_ADD_EVENT_FILTER} - - - ), - [handleAddEventFilterClick] - ); + }, [closePopover, onAddEventFilterClick]); - const statusFilters = useMemo(() => { - if (!alertStatus) { - return []; - } + const exceptionActions = useExceptionActions({ + isEndpointAlert, + onAddExceptionTypeClick: handleOnAddExceptionTypeClick, + }); - switch (alertStatus) { - case 'open': - return [inProgressAlertActionComponent, closeAlertActionComponent]; - case 'in-progress': - return [openAlertActionComponent, closeAlertActionComponent]; - case 'closed': - return [openAlertActionComponent, inProgressAlertActionComponent]; - default: - return []; - } - }, [ - closeAlertActionComponent, - inProgressAlertActionComponent, - openAlertActionComponent, - alertStatus, - ]); - - const items = useMemo( - () => - !isEvent && ruleId - ? [...statusFilters, addEndpointExceptionComponent, addExceptionComponent] - : [addEventFilterComponent], - [ - addEndpointExceptionComponent, - addExceptionComponent, - addEventFilterComponent, - statusFilters, - ruleId, - isEvent, - ] + const eventFilterActions = useEventFilterAction({ + onAddEventFilterClick: handleOnAddEventFilterClick, + }); + + const panels = useMemo( + () => [ + { + id: 0, + items: !isEvent && ruleId ? [...statusActions, ...exceptionActions] : [eventFilterActions], + }, + ], + [eventFilterActions, exceptionActions, isEvent, ruleId, statusActions] ); return ( @@ -444,23 +164,26 @@ const AlertContextMenuComponent: React.FC = ({ anchorPosition="downLeft" repositionOnScroll > - + - {exceptionModalType != null && ruleId != null && ecsRowData != null && ( - - )} + {exceptionModalType != null && + ruleId != null && + ruleName != null && + ecsRowData?._id != null && ( + + )} {isAddEventFilterModalOpen && ecsRowData != null && ( )} @@ -468,7 +191,7 @@ const AlertContextMenuComponent: React.FC = ({ ); }; -const ContextMenuPanel = styled(EuiContextMenuPanel)` +const ContextMenuPanel = styled(EuiContextMenu)` font-size: ${({ theme }) => theme.eui.euiFontSizeS}; `; @@ -480,7 +203,7 @@ type AddExceptionModalWrapperProps = Omit< AddExceptionModalProps, 'alertData' | 'isAlertDataLoading' > & { - ecsData: Ecs; + eventId?: string; }; /** @@ -488,12 +211,12 @@ type AddExceptionModalWrapperProps = Omit< * Due to the conditional nature of the modal and how we use the `ecsData` field, * we cannot use the fetch hook within the modal component itself */ -const AddExceptionModalWrapper: React.FC = ({ +export const AddExceptionModalWrapper: React.FC = ({ ruleName, ruleId, ruleIndices, exceptionListType, - ecsData, + eventId, onCancel, onConfirm, alertStatus, @@ -502,7 +225,7 @@ const AddExceptionModalWrapper: React.FC = ({ const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); const { loading: isLoadingAlertData, data } = useQueryAlerts({ - query: buildGetAlertByIdQuery(ecsData?._id), + query: buildGetAlertByIdQuery(eventId), indexName: signalIndexName, }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/close_status.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/close_status.tsx new file mode 100644 index 0000000000000..038d58c38a013 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/close_status.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiContextMenuItem, EuiText } from '@elastic/eui'; +import React from 'react'; +import { FILTER_CLOSED } from '../../alerts_filter_group'; +import * as i18n from '../../translations'; + +interface CloseAlertActionProps { + onClick: () => void; + disabled?: boolean; +} + +const CloseAlertActionComponent: React.FC = ({ onClick, disabled }) => { + return ( + + {i18n.ACTION_CLOSE_ALERT} + + ); +}; + +export const CloseAlertAction = React.memo(CloseAlertActionComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/in_progress_alert_status.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/in_progress_alert_status.tsx new file mode 100644 index 0000000000000..2bca569032827 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/in_progress_alert_status.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiContextMenuItem, EuiText } from '@elastic/eui'; +import React from 'react'; +import { FILTER_IN_PROGRESS } from '../../alerts_filter_group'; +import * as i18n from '../../translations'; + +interface InProgressAlertStatusProps { + onClick: () => void; + disabled?: boolean; +} + +const InProgressAlertStatusComponent: React.FC = ({ + onClick, + disabled, +}) => { + return ( + + {i18n.ACTION_IN_PROGRESS_ALERT} + + ); +}; + +export const InProgressAlertStatus = React.memo(InProgressAlertStatusComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/open_alert_status.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/open_alert_status.tsx new file mode 100644 index 0000000000000..34832ee07ea75 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alerts_status_actions/open_alert_status.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiContextMenuItem, EuiText } from '@elastic/eui'; +import React from 'react'; +import { FILTER_OPEN } from '../../alerts_filter_group'; +import * as i18n from '../../translations'; + +interface OpenAlertStatusProps { + onClick: () => void; + disabled?: boolean; +} + +const OpenAlertStatusComponent: React.FC = ({ onClick, disabled }) => { + return ( + + {i18n.ACTION_OPEN_ALERT} + + ); +}; + +export const OpenAlertStatus = React.memo(OpenAlertStatusComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx index 3bf30d57d4a8a..04cba8332553a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/investigate_in_timeline_action.tsx @@ -5,102 +5,41 @@ * 2.0. */ -import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import React from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; -import { TimelineId } from '../../../../../common/types/timeline'; import { Ecs } from '../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline'; -import { timelineActions } from '../../../../timelines/store/timeline'; -import { sendAlertToTimelineAction } from '../actions'; -import { dispatchUpdateTimeline } from '../../../../timelines/components/open_timeline/helpers'; import { ActionIconItem } from '../../../../timelines/components/timeline/body/actions/action_icon_item'; -import { CreateTimelineProps } from '../types'; + import { ACTION_INVESTIGATE_IN_TIMELINE, ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL, } from '../translations'; +import { useInvestigateInTimeline } from './use_investigate_in_timeline'; interface InvestigateInTimelineActionProps { - ecsRowData: Ecs | Ecs[] | null; - nonEcsRowData: TimelineNonEcsData[]; + ecsRowData?: Ecs | Ecs[] | null; + nonEcsRowData?: TimelineNonEcsData[]; ariaLabel?: string; alertIds?: string[]; - fetchEcsAlertsData?: (alertIds?: string[]) => Promise; + buttonType?: 'text' | 'icon'; + onInvestigateInTimelineAlertClick?: () => void; } const InvestigateInTimelineActionComponent: React.FC = ({ ariaLabel = ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL, alertIds, ecsRowData, - fetchEcsAlertsData, nonEcsRowData, + buttonType, + onInvestigateInTimelineAlertClick, }) => { - const { - data: { search: searchStrategyClient }, - } = useKibana().services; - const dispatch = useDispatch(); - - const updateTimelineIsLoading = useCallback( - (payload) => dispatch(timelineActions.updateIsLoading(payload)), - [dispatch] - ); - - const createTimeline = useCallback( - ({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => { - updateTimelineIsLoading({ id: TimelineId.active, isLoading: false }); - dispatchUpdateTimeline(dispatch)({ - duplicate: true, - from: fromTimeline, - id: TimelineId.active, - notes: [], - timeline: { - ...timeline, - // by setting as an empty array, it will default to all in the reducer because of the event type - indexNames: [], - show: true, - }, - to: toTimeline, - ruleNote, - })(); - }, - [dispatch, updateTimelineIsLoading] - ); - - const investigateInTimelineAlertClick = useCallback(async () => { - try { - if (ecsRowData != null) { - await sendAlertToTimelineAction({ - createTimeline, - ecsData: ecsRowData, - nonEcsData: nonEcsRowData, - searchStrategyClient, - updateTimelineIsLoading, - }); - } - if (ecsRowData == null && fetchEcsAlertsData) { - const alertsEcsData = await fetchEcsAlertsData(alertIds); - await sendAlertToTimelineAction({ - createTimeline, - ecsData: alertsEcsData, - nonEcsData: nonEcsRowData, - searchStrategyClient, - updateTimelineIsLoading, - }); - } - } catch { - // TODO show a toaster that something went wrong - } - }, [ - alertIds, - createTimeline, + const { investigateInTimelineAlertClick } = useInvestigateInTimeline({ ecsRowData, - fetchEcsAlertsData, nonEcsRowData, - searchStrategyClient, - updateTimelineIsLoading, - ]); + alertIds, + onInvestigateInTimelineAlertClick, + }); return ( ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx new file mode 100644 index 0000000000000..0f8fa00a3ac40 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useMemo } from 'react'; +import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; + +import { useUserData } from '../../user_info'; +import { ACTION_ADD_ENDPOINT_EXCEPTION, ACTION_ADD_EXCEPTION } from '../translations'; + +interface UseExceptionActions { + name: string; + onClick: () => void; + disabled: boolean; +} + +interface UseExceptionActionProps { + isEndpointAlert: boolean; + onAddExceptionTypeClick: (type: ExceptionListType) => void; +} + +export const useExceptionActions = ({ + isEndpointAlert, + onAddExceptionTypeClick, +}: UseExceptionActionProps): UseExceptionActions[] => { + const [{ canUserCRUD, hasIndexWrite }] = useUserData(); + + const handleDetectionExceptionModal = useCallback(() => { + onAddExceptionTypeClick('detection'); + }, [onAddExceptionTypeClick]); + + const handleEndpointExceptionModal = useCallback(() => { + onAddExceptionTypeClick('endpoint'); + }, [onAddExceptionTypeClick]); + + const disabledAddEndpointException = !canUserCRUD || !hasIndexWrite || !isEndpointAlert; + const disabledAddException = !canUserCRUD || !hasIndexWrite; + + const exceptionActions = useMemo( + () => [ + { + name: ACTION_ADD_ENDPOINT_EXCEPTION, + onClick: handleEndpointExceptionModal, + disabled: disabledAddEndpointException, + [`data-test-subj`]: 'add-endpoint-exception-menu-item', + }, + { + name: ACTION_ADD_EXCEPTION, + onClick: handleDetectionExceptionModal, + disabled: disabledAddException, + [`data-test-subj`]: 'add-exception-menu-item', + }, + ], + [ + disabledAddEndpointException, + disabledAddException, + handleDetectionExceptionModal, + handleEndpointExceptionModal, + ] + ); + + return exceptionActions; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_modal.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_modal.tsx new file mode 100644 index 0000000000000..e623438213433 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_modal.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useMemo, useState } from 'react'; +import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; + +import { + DEFAULT_INDEX_PATTERN, + DEFAULT_INDEX_PATTERN_EXPERIMENTAL, +} from '../../../../../common/constants'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { TimelineId } from '../../../../../common/types/timeline'; +import { inputsModel } from '../../../../common/store'; + +interface UseExceptionModalProps { + ruleIndex: string[] | null | undefined; + refetch?: inputsModel.Refetch; + timelineId: string; +} +interface UseExceptionModal { + exceptionModalType: ExceptionListType | null; + onAddExceptionTypeClick: (type: ExceptionListType) => void; + onAddExceptionCancel: () => void; + onAddExceptionConfirm: (didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; + ruleIndices: string[]; +} + +export const useExceptionModal = ({ + ruleIndex, + refetch, + timelineId, +}: UseExceptionModalProps): UseExceptionModal => { + const [exceptionModalType, setOpenAddExceptionModal] = useState(null); + + // TODO: Steph/ueba remove when past experimental + const uebaEnabled = useIsExperimentalFeatureEnabled('uebaEnabled'); + + const ruleIndices = useMemo((): string[] => { + if (ruleIndex != null) { + return ruleIndex; + } else { + return uebaEnabled + ? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL] + : DEFAULT_INDEX_PATTERN; + } + }, [ruleIndex, uebaEnabled]); + + const onAddExceptionTypeClick = useCallback((exceptionListType: ExceptionListType): void => { + setOpenAddExceptionModal(exceptionListType); + }, []); + + const onAddExceptionCancel = useCallback(() => { + setOpenAddExceptionModal(null); + }, []); + + const onAddExceptionConfirm = useCallback( + (didCloseAlert: boolean, didBulkCloseAlert) => { + if (refetch && (timelineId !== TimelineId.active || didBulkCloseAlert)) { + refetch(); + } + setOpenAddExceptionModal(null); + }, + [refetch, timelineId] + ); + + return { + exceptionModalType, + onAddExceptionTypeClick, + onAddExceptionCancel, + onAddExceptionConfirm, + ruleIndices, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx new file mode 100644 index 0000000000000..855eb2dd5fef4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx @@ -0,0 +1,217 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; + +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { timelineActions } from '../../../../timelines/store/timeline'; +import { FILTER_OPEN, FILTER_CLOSED, FILTER_IN_PROGRESS } from '../alerts_filter_group'; +import { updateAlertStatusAction } from '../actions'; +import { SetEventsDeletedProps, SetEventsLoadingProps } from '../types'; +import * as i18nCommon from '../../../../common/translations'; +import * as i18n from '../translations'; + +import { + useStateToaster, + displaySuccessToast, + displayErrorToast, +} from '../../../../common/components/toasters'; +import { useUserData } from '../../user_info'; + +interface Props { + alertStatus?: string; + closePopover: () => void; + eventId: string | null | undefined; + timelineId: string; +} + +export const useAlertsActions = ({ alertStatus, closePopover, eventId, timelineId }: Props) => { + const dispatch = useDispatch(); + const [, dispatchToaster] = useStateToaster(); + + const { addWarning } = useAppToasts(); + + const [{ canUserCRUD, hasIndexMaintenance, hasIndexUpdateDelete }] = useUserData(); + + const onAlertStatusUpdateSuccess = useCallback( + (updated: number, conflicts: number, newStatus: Status) => { + if (conflicts > 0) { + // Partial failure + addWarning({ + title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } else { + let title: string; + switch (newStatus) { + case 'closed': + title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated); + break; + case 'open': + title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated); + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated); + } + + displaySuccessToast(title, dispatchToaster); + } + }, + [addWarning, dispatchToaster] + ); + + const onAlertStatusUpdateFailure = useCallback( + (newStatus: Status, error: Error) => { + let title: string; + switch (newStatus) { + case 'closed': + title = i18n.CLOSED_ALERT_FAILED_TOAST; + break; + case 'open': + title = i18n.OPENED_ALERT_FAILED_TOAST; + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_FAILED_TOAST; + } + displayErrorToast(title, [error.message], dispatchToaster); + }, + [dispatchToaster] + ); + + const setEventsLoading = useCallback( + ({ eventIds, isLoading }: SetEventsLoadingProps) => { + dispatch(timelineActions.setEventsLoading({ id: timelineId, eventIds, isLoading })); + }, + [dispatch, timelineId] + ); + + const setEventsDeleted = useCallback( + ({ eventIds, isDeleted }: SetEventsDeletedProps) => { + dispatch(timelineActions.setEventsDeleted({ id: timelineId, eventIds, isDeleted })); + }, + [dispatch, timelineId] + ); + + const openAlertActionOnClick = useCallback(() => { + if (eventId) { + updateAlertStatusAction({ + alertIds: [eventId], + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + selectedStatus: FILTER_OPEN, + }); + } + closePopover(); + }, [ + closePopover, + eventId, + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + ]); + + const closeAlertActionClick = useCallback(() => { + if (eventId) { + updateAlertStatusAction({ + alertIds: [eventId], + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + selectedStatus: FILTER_CLOSED, + }); + } + + closePopover(); + }, [ + closePopover, + eventId, + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + ]); + + const inProgressAlertActionClick = useCallback(() => { + if (eventId) { + updateAlertStatusAction({ + alertIds: [eventId], + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + selectedStatus: FILTER_IN_PROGRESS, + }); + } + + closePopover(); + }, [ + closePopover, + eventId, + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + setEventsDeleted, + setEventsLoading, + ]); + + const disabledInProgressAlertAction = !canUserCRUD || !hasIndexUpdateDelete; + + const inProgressAlertAction = useMemo(() => { + return { + name: i18n.ACTION_IN_PROGRESS_ALERT, + disabled: disabledInProgressAlertAction, + onClick: inProgressAlertActionClick, + [`data-test-subj`]: 'in-progress-alert-status', + }; + }, [disabledInProgressAlertAction, inProgressAlertActionClick]); + + const disabledCloseAlertAction = !hasIndexUpdateDelete && !hasIndexMaintenance; + const closeAlertAction = useMemo(() => { + return { + name: i18n.ACTION_CLOSE_ALERT, + disabled: disabledCloseAlertAction, + onClick: closeAlertActionClick, + [`data-test-subj`]: 'close-alert-status', + }; + }, [disabledCloseAlertAction, closeAlertActionClick]); + + const disabledOpenAlertAction = !hasIndexUpdateDelete && !hasIndexMaintenance; + const openAlertAction = useMemo(() => { + return { + name: i18n.ACTION_OPEN_ALERT, + disabled: disabledOpenAlertAction, + onClick: openAlertActionOnClick, + [`data-test-subj`]: 'open-alert-status', + }; + }, [disabledOpenAlertAction, openAlertActionOnClick]); + + const statusActions = useMemo(() => { + if (!alertStatus) { + return []; + } + + switch (alertStatus) { + case 'open': + return [inProgressAlertAction, closeAlertAction]; + case 'in-progress': + return [openAlertAction, closeAlertAction]; + case 'closed': + return [openAlertAction, inProgressAlertAction]; + default: + return []; + } + }, [alertStatus, inProgressAlertAction, closeAlertAction, openAlertAction]); + + return { + statusActions, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx new file mode 100644 index 0000000000000..c24a1e0223ede --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_action.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { ACTION_ADD_EVENT_FILTER } from '../translations'; + +export const useEventFilterAction = ({ + onAddEventFilterClick, +}: { + onAddEventFilterClick: () => void; +}) => { + const eventFilterActions = useMemo( + () => ({ + name: ACTION_ADD_EVENT_FILTER, + onClick: onAddEventFilterClick, + }), + [onAddEventFilterClick] + ); + return eventFilterActions; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_modal.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_modal.tsx new file mode 100644 index 0000000000000..88917a6428cc8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_event_filter_modal.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useState } from 'react'; + +export const useEventFilterModal = () => { + const [isAddEventFilterModalOpen, setIsAddEventFilterModalOpen] = useState(false); + + const onAddEventFilterClick = useCallback((): void => { + setIsAddEventFilterModalOpen(true); + }, []); + const closeAddEventFilterModal = useCallback((): void => { + setIsAddEventFilterModalOpen(false); + }, []); + + return { closeAddEventFilterModal, isAddEventFilterModalOpen, onAddEventFilterClick }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx new file mode 100644 index 0000000000000..0671101f47a37 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_investigate_in_timeline.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { useDispatch } from 'react-redux'; + +import { useKibana } from '../../../../common/lib/kibana'; + +import { TimelineId } from '../../../../../common/types/timeline'; +import { Ecs } from '../../../../../common/ecs'; +import { TimelineNonEcsData } from '../../../../../common/search_strategy/timeline'; +import { timelineActions } from '../../../../timelines/store/timeline'; +import { sendAlertToTimelineAction } from '../actions'; +import { dispatchUpdateTimeline } from '../../../../timelines/components/open_timeline/helpers'; +import { CreateTimelineProps } from '../types'; +import { ACTION_INVESTIGATE_IN_TIMELINE } from '../translations'; +import { useFetchEcsAlertsData } from '../../../containers/detection_engine/alerts/use_fetch_ecs_alerts_data'; + +interface UseInvestigateInTimelineActionProps { + ecsRowData?: Ecs | Ecs[] | null; + nonEcsRowData?: TimelineNonEcsData[]; + alertIds?: string[] | null | undefined; + onInvestigateInTimelineAlertClick?: () => void; +} + +export const useInvestigateInTimeline = ({ + ecsRowData, + nonEcsRowData, + alertIds, + onInvestigateInTimelineAlertClick, +}: UseInvestigateInTimelineActionProps) => { + const { + data: { search: searchStrategyClient }, + } = useKibana().services; + const dispatch = useDispatch(); + + const updateTimelineIsLoading = useCallback( + (payload) => dispatch(timelineActions.updateIsLoading(payload)), + [dispatch] + ); + + const createTimeline = useCallback( + ({ from: fromTimeline, timeline, to: toTimeline, ruleNote }: CreateTimelineProps) => { + updateTimelineIsLoading({ id: TimelineId.active, isLoading: false }); + dispatchUpdateTimeline(dispatch)({ + duplicate: true, + from: fromTimeline, + id: TimelineId.active, + notes: [], + timeline: { + ...timeline, + // by setting as an empty array, it will default to all in the reducer because of the event type + indexNames: [], + show: true, + }, + to: toTimeline, + ruleNote, + })(); + }, + [dispatch, updateTimelineIsLoading] + ); + + const showInvestigateInTimelineAction = alertIds != null; + const { isLoading: isFetchingAlertEcs, alertsEcsData } = useFetchEcsAlertsData({ + alertIds, + skip: ecsRowData != null || alertIds == null, + }); + + const investigateInTimelineAlertClick = useCallback(async () => { + if (onInvestigateInTimelineAlertClick) { + onInvestigateInTimelineAlertClick(); + } + if (alertsEcsData != null) { + await sendAlertToTimelineAction({ + createTimeline, + ecsData: alertsEcsData, + nonEcsData: nonEcsRowData ?? [], + searchStrategyClient, + updateTimelineIsLoading, + }); + } + + if (ecsRowData != null) { + await sendAlertToTimelineAction({ + createTimeline, + ecsData: ecsRowData, + nonEcsData: nonEcsRowData ?? [], + searchStrategyClient, + updateTimelineIsLoading, + }); + } + }, [ + alertsEcsData, + createTimeline, + ecsRowData, + nonEcsRowData, + onInvestigateInTimelineAlertClick, + searchStrategyClient, + updateTimelineIsLoading, + ]); + + const investigateInTimelineAction = showInvestigateInTimelineAction + ? [ + { + name: ACTION_INVESTIGATE_IN_TIMELINE, + onClick: investigateInTimelineAlertClick, + disabled: isFetchingAlertEcs, + }, + ] + : []; + + return { + investigateInTimelineAction, + investigateInTimelineAlertClick, + showInvestigateInTimelineAction, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index c63b4b73ae315..badc077244acd 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -178,6 +178,13 @@ export const ACTION_ADD_EVENT_FILTER = i18n.translate( } ); +export const ACTION_ADD_TO_CASE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.actions.addToCase', + { + defaultMessage: 'Add to case', + } +); + export const ACTION_ADD_ENDPOINT_EXCEPTION = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.actions.addEndpointException', { diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts new file mode 100644 index 0000000000000..aa08db0a23669 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/helpers.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { find } from 'lodash/fp'; + +import type { TimelineEventsDetailsItem } from '../../../../common'; + +export const getFieldValue = ( + { + category, + field, + }: { + category: string; + field: string; + }, + data: TimelineEventsDetailsItem[] | null +) => { + const currentField = find({ category, field }, data)?.values; + return currentField && currentField.length > 0 ? currentField[0] : ''; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx deleted file mode 100644 index 1404f7927d6ec..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/host_isolation/take_action_dropdown.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState, useCallback, useMemo } from 'react'; -import { EuiContextMenuItem, EuiContextMenuPanel, EuiButton, EuiPopover } from '@elastic/eui'; -import { ISOLATE_HOST, UNISOLATE_HOST } from './translations'; -import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations'; -import { useHostIsolationStatus } from '../../containers/detection_engine/alerts/use_host_isolation_status'; -import { HostStatus } from '../../../../common/endpoint/types'; - -export const TakeActionDropdown = React.memo( - ({ - onChange, - agentId, - }: { - onChange: (action: 'isolateHost' | 'unisolateHost') => void; - agentId: string; - }) => { - const { loading, isIsolated: isolationStatus, agentStatus } = useHostIsolationStatus({ - agentId, - }); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const closePopoverHandler = useCallback(() => { - setIsPopoverOpen(false); - }, []); - - const isolateHostHandler = useCallback(() => { - setIsPopoverOpen(false); - if (isolationStatus === false) { - onChange('isolateHost'); - } else { - onChange('unisolateHost'); - } - }, [onChange, isolationStatus]); - - const takeActionButton = useMemo(() => { - return ( - { - setIsPopoverOpen(!isPopoverOpen); - }} - > - {TAKE_ACTION} - - ); - }, [isPopoverOpen, loading, agentStatus]); - - return ( - - - {isolationStatus === false ? ( - - {ISOLATE_HOST} - - ) : ( - - {UNISOLATE_HOST} - - )} - - - ); - } -); - -TakeActionDropdown.displayName = 'TakeActionDropdown'; diff --git a/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx new file mode 100644 index 0000000000000..d7e54e7f9900b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/host_isolation/use_host_isolation_action.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useCallback, useMemo } from 'react'; +import type { TimelineEventsDetailsItem } from '../../../../common'; +import { isIsolationSupported } from '../../../../common/endpoint/service/host_isolation/utils'; +import { HostStatus } from '../../../../common/endpoint/types'; +import { useIsolationPrivileges } from '../../../common/hooks/endpoint/use_isolate_privileges'; +import { endpointAlertCheck } from '../../../common/utils/endpoint_alert_check'; +import { useHostIsolationStatus } from '../../containers/detection_engine/alerts/use_host_isolation_status'; +import { ISOLATE_HOST, UNISOLATE_HOST } from './translations'; +import { getFieldValue } from './helpers'; + +interface UseHostIsolationActionProps { + closePopover: () => void; + detailsData: TimelineEventsDetailsItem[] | null; + isHostIsolationPanelOpen: boolean; + onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; +} + +export const useHostIsolationAction = ({ + closePopover, + detailsData, + isHostIsolationPanelOpen, + onAddIsolationStatusClick, +}: UseHostIsolationActionProps) => { + const isEndpointAlert = useMemo(() => { + return endpointAlertCheck({ data: detailsData || [] }); + }, [detailsData]); + + const agentId = useMemo( + () => getFieldValue({ category: 'agent', field: 'agent.id' }, detailsData), + [detailsData] + ); + + const hostOsFamily = useMemo( + () => getFieldValue({ category: 'host', field: 'host.os.name' }, detailsData), + [detailsData] + ); + + const agentVersion = useMemo( + () => getFieldValue({ category: 'agent', field: 'agent.version' }, detailsData), + [detailsData] + ); + + const isolationSupported = isIsolationSupported({ + osName: hostOsFamily, + version: agentVersion, + }); + + const { + loading: loadingHostIsolationStatus, + isIsolated: isolationStatus, + agentStatus, + } = useHostIsolationStatus({ + agentId, + }); + + const { isAllowed: isIsolationAllowed } = useIsolationPrivileges(); + + const isolateHostHandler = useCallback(() => { + closePopover(); + if (isolationStatus === false) { + onAddIsolationStatusClick('isolateHost'); + } else { + onAddIsolationStatusClick('unisolateHost'); + } + }, [closePopover, isolationStatus, onAddIsolationStatusClick]); + + const isolateHostTitle = isolationStatus === false ? ISOLATE_HOST : UNISOLATE_HOST; + + const hostIsolationAction = useMemo( + () => + isIsolationAllowed && + isEndpointAlert && + isolationSupported && + isHostIsolationPanelOpen === false + ? [ + { + name: isolateHostTitle, + onClick: isolateHostHandler, + disabled: loadingHostIsolationStatus || agentStatus === HostStatus.UNENROLLED, + }, + ] + : [], + [ + agentStatus, + isEndpointAlert, + isHostIsolationPanelOpen, + isIsolationAllowed, + isolateHostHandler, + isolateHostTitle, + isolationSupported, + loadingHostIsolationStatus, + ] + ); + return hostIsolationAction; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/helpers.ts new file mode 100644 index 0000000000000..22f147494a2d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/helpers.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ACTION_ADD_TO_CASE } from '../alerts_table/translations'; + +export const addToCaseActionItem = (timelineId: string | null | undefined) => + ['detections-page', 'detections-rules-details-page', 'timeline-1'].includes(timelineId ?? '') + ? [ + { + name: ACTION_ADD_TO_CASE, + panel: 2, + }, + ] + : []; diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx new file mode 100644 index 0000000000000..d0f26894bf7d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx @@ -0,0 +1,239 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { EuiContextMenu, EuiButton, EuiPopover } from '@elastic/eui'; +import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; + +import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations'; + +import { TimelineEventsDetailsItem, TimelineNonEcsData } from '../../../../common'; +import { useExceptionActions } from '../alerts_table/timeline_actions/use_add_exception_actions'; +import { useAlertsActions } from '../alerts_table/timeline_actions/use_alerts_actions'; +import { useInvestigateInTimeline } from '../alerts_table/timeline_actions/use_investigate_in_timeline'; +/* Todo: Uncomment case action after getAddToCaseAction is split into action and modal +import { + ACTION_ADD_TO_CASE +} from '../alerts_table/translations'; +import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana'; +import { useInsertTimeline } from '../../../cases/components/use_insert_timeline'; +import { addToCaseActionItem } from './helpers'; */ +import { useEventFilterAction } from '../alerts_table/timeline_actions/use_event_filter_action'; +import { useHostIsolationAction } from '../host_isolation/use_host_isolation_action'; +import { CHANGE_ALERT_STATUS } from './translations'; +import { getFieldValue } from '../host_isolation/helpers'; +import type { Ecs } from '../../../../common/ecs'; +import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; +import { endpointAlertCheck } from '../../../common/utils/endpoint_alert_check'; + +interface ActionsData { + alertStatus: Status; + eventId: string; + eventKind: string; + ruleId: string; + ruleName: string; +} + +export const TakeActionDropdown = React.memo( + ({ + detailsData, + ecsData, + handleOnEventClosed, + isHostIsolationPanelOpen, + loadingEventDetails, + nonEcsData, + onAddEventFilterClick, + onAddExceptionTypeClick, + onAddIsolationStatusClick, + refetch, + timelineId, + }: { + detailsData: TimelineEventsDetailsItem[] | null; + ecsData?: Ecs; + handleOnEventClosed: () => void; + isHostIsolationPanelOpen: boolean; + loadingEventDetails: boolean; + nonEcsData?: TimelineNonEcsData[]; + refetch: (() => void) | undefined; + onAddEventFilterClick: () => void; + onAddExceptionTypeClick: (type: ExceptionListType) => void; + onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; + timelineId: string; + }) => { + /* Todo: Uncomment case action after getAddToCaseAction is split into action and modal + const casePermissions = useGetUserCasesPermissions(); + const { timelines: timelinesUi } = useKibana().services; + const insertTimelineHook = useInsertTimeline; + */ + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const actionsData = useMemo( + () => + [ + { category: 'signal', field: 'signal.rule.id', name: 'ruleId' }, + { category: 'signal', field: 'signal.rule.name', name: 'ruleName' }, + { category: 'signal', field: 'signal.status', name: 'alertStatus' }, + { category: 'event', field: 'event.kind', name: 'eventKind' }, + { category: '_id', field: '_id', name: 'eventId' }, + ].reduce( + (acc, curr) => ({ + ...acc, + [curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData), + }), + {} as ActionsData + ), + [detailsData] + ); + + const alertIds = useMemo(() => [actionsData.eventId], [actionsData.eventId]); + const isEvent = actionsData.eventKind === 'event'; + + const isEndpointAlert = useMemo((): boolean => { + if (detailsData == null) { + return false; + } + return endpointAlertCheck({ data: detailsData }); + }, [detailsData]); + + const togglePopoverHandler = useCallback(() => { + setIsPopoverOpen(!isPopoverOpen); + }, [isPopoverOpen]); + + const closePopoverHandler = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const closePopoverAndFlyout = useCallback(() => { + handleOnEventClosed(); + setIsPopoverOpen(false); + }, [handleOnEventClosed]); + + const handleOnAddIsolationStatusClick = useCallback( + (action: 'isolateHost' | 'unisolateHost') => { + onAddIsolationStatusClick(action); + setIsPopoverOpen(false); + }, + [onAddIsolationStatusClick] + ); + + const hostIsolationAction = useHostIsolationAction({ + closePopover: closePopoverHandler, + detailsData, + onAddIsolationStatusClick: handleOnAddIsolationStatusClick, + isHostIsolationPanelOpen, + }); + + const handleOnAddExceptionTypeClick = useCallback( + (type: ExceptionListType) => { + onAddExceptionTypeClick(type); + setIsPopoverOpen(false); + }, + [onAddExceptionTypeClick] + ); + + const exceptionActions = useExceptionActions({ + isEndpointAlert, + onAddExceptionTypeClick: handleOnAddExceptionTypeClick, + }); + + const handleOnAddEventFilterClick = useCallback(() => { + onAddEventFilterClick(); + setIsPopoverOpen(false); + }, [onAddEventFilterClick]); + + const eventFilterActions = useEventFilterAction({ + onAddEventFilterClick: handleOnAddEventFilterClick, + }); + + const { statusActions } = useAlertsActions({ + alertStatus: actionsData.alertStatus, + eventId: actionsData.eventId, + timelineId, + closePopover: closePopoverAndFlyout, + }); + + const { investigateInTimelineAction } = useInvestigateInTimeline({ + alertIds, + ecsRowData: ecsData, + onInvestigateInTimelineAlertClick: closePopoverHandler, + }); + + const alertsActionItems = useMemo( + () => + !isEvent && actionsData.ruleId + ? [ + { + name: CHANGE_ALERT_STATUS, + panel: 1, + }, + ...exceptionActions, + ] + : [eventFilterActions], + [eventFilterActions, exceptionActions, isEvent, actionsData.ruleId] + ); + + const panels = useMemo( + () => [ + { + id: 0, + items: [ + ...alertsActionItems, + /* Todo: Uncomment case action after getAddToCaseAction is split into action and modal + ...addToCaseActionItem(timelineId),*/ + ...hostIsolationAction, + ...investigateInTimelineAction, + ], + }, + { + id: 1, + title: CHANGE_ALERT_STATUS, + items: statusActions, + }, + /* Todo: Uncomment case action after getAddToCaseAction is split into action and modal + { + id: 2, + title: ACTION_ADD_TO_CASE, + content: ( + <> + {ecsData && + timelinesUi.getAddToCaseAction({ + ecsRowData: ecsData, + useInsertTimeline: insertTimelineHook, + casePermissions, + showIcon: false, + })} + + ), + },*/ + ], + [alertsActionItems, hostIsolationAction, investigateInTimelineAction, statusActions] + ); + + const takeActionButton = useMemo(() => { + return ( + + {TAKE_ACTION} + + ); + }, [togglePopoverHandler]); + + return panels[0].items?.length && !loadingEventDetails ? ( + <> + + + + + ) : null; + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/translations.ts b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/translations.ts new file mode 100644 index 0000000000000..f8ddb98a7ed86 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/translations.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CHANGE_ALERT_STATUS = i18n.translate( + 'xpack.securitySolution.endpoint.takeAction.changeAlertStatus', + { + defaultMessage: 'Change alert status', + } +); + +export const ACTION_ADD_ENDPOINT_EXCEPTION = i18n.translate( + 'xpack.securitySolution.endpoint.takeAction.addEndpointException', + { + defaultMessage: 'Add Endpoint exception', + } +); + +export const ACTION_ADD_EXCEPTION = i18n.translate( + 'xpack.securitySolution.endpoint.takeAction.addException', + { + defaultMessage: 'Add rule exception', + } +); + +export const ACTION_ADD_EVENT_FILTER = i18n.translate( + 'xpack.securitySolution.endpoint.takeAction.addEventFilter', + { + defaultMessage: 'Add Endpoint event filter', + } +); + +export const INVESTIGATE_IN_TIMELINE = i18n.translate( + 'xpack.securitySolution.endpoint.takeAction.investigateInTimeline', + { + defaultMessage: 'investigate in timeline', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data.ts new file mode 100644 index 0000000000000..8af4781284924 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useEffect, useState } from 'react'; +import { SearchResponse } from 'elasticsearch'; +import { isEmpty } from 'lodash'; + +import { + buildAlertsQuery, + formatAlertToEcsSignal, +} from '../../../../cases/components/case_view/helpers'; +import { Ecs } from '../../../../../common/ecs'; + +import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../../common/constants'; +import { KibanaServices } from '../../../../common/lib/kibana'; + +export const useFetchEcsAlertsData = ({ + alertIds, + skip, + onError, +}: { + alertIds?: string[] | null | undefined; + skip?: boolean; + onError?: (e: Error) => void; +}): { isLoading: boolean | null; alertsEcsData: Ecs[] | null } => { + const [isLoading, setIsLoading] = useState(null); + const [alertsEcsData, setAlertEcsData] = useState(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchAlert = async () => { + try { + setIsLoading(true); + const alertResponse = await KibanaServices.get().http.fetch< + SearchResponse<{ '@timestamp': string; [key: string]: unknown }> + >(DETECTION_ENGINE_QUERY_SIGNALS_URL, { + method: 'POST', + body: JSON.stringify(buildAlertsQuery(alertIds ?? [])), + }); + + setAlertEcsData( + alertResponse?.hits.hits.reduce( + (acc, { _id, _index, _source }) => [ + ...acc, + { + ...formatAlertToEcsSignal(_source as {}), + _id, + _index, + timestamp: _source['@timestamp'], + }, + ], + [] + ) ?? [] + ); + } catch (e) { + if (isSubscribed) { + if (onError) { + onError(e); + } + } + } + if (isSubscribed) { + setIsLoading(false); + } + }; + + if (!isEmpty(alertIds) && !skip) { + fetchAlert(); + } + + return (): void => { + isSubscribed = false; + abortCtrl.abort(); + }; + }, [alertIds, onError, skip]); + + return { + isLoading, + alertsEcsData, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx index 6a40898d0a109..4737f13c9c596 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_host_isolation_status.tsx @@ -38,6 +38,8 @@ export const useHostIsolationStatus = ({ // isMounted tracks if a component is mounted before changing state let isMounted = true; let fleetAgentId: string; + setLoading(true); + const fetchData = async () => { try { const metadataResponse = await getHostMetadata({ agentId, signal: abortCtrl.signal }); @@ -73,15 +75,9 @@ export const useHostIsolationStatus = ({ } }; - setLoading((prevState) => { - if (prevState) { - return prevState; - } - if (!isEmpty(agentId)) { - fetchData(); - } - return true; - }); + if (!isEmpty(agentId)) { + fetchData(); + } return () => { // updates to show component is unmounted isMounted = false; diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 8118555cd64d8..3b365306447b4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -261,7 +261,7 @@ Array [ -ms-flex: 1; flex: 1; overflow: hidden; - padding: 4px 16px 50px; + padding: 16px; } + + +
+ +
+ +
+ +
+
+
+
+
+
+
, @@ -480,7 +527,7 @@ Array [ -ms-flex: 1; flex: 1; overflow: hidden; - padding: 4px 16px 50px; + padding: 16px; }
+ + +
+ +
+ +
+ +
+
+
+
+
+
+
, ] diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx new file mode 100644 index 0000000000000..cb8ed537543a0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/footer.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { find, get } from 'lodash/fp'; +import { TakeActionDropdown } from '../../../../detections/components/take_action_dropdown'; +import type { TimelineEventsDetailsItem } from '../../../../../common'; +import { useExceptionModal } from '../../../../detections/components/alerts_table/timeline_actions/use_add_exception_modal'; +import { AddExceptionModalWrapper } from '../../../../detections/components/alerts_table/timeline_actions/alert_context_menu'; +import { EventFiltersModal } from '../../../../management/pages/event_filters/view/components/modal'; +import { useEventFilterModal } from '../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal'; +import { getFieldValue } from '../../../../detections/components/host_isolation/helpers'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { useFetchEcsAlertsData } from '../../../../detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data'; + +interface EventDetailsFooterProps { + detailsData: TimelineEventsDetailsItem[] | null; + expandedEvent: { + eventId: string; + indexName: string; + refetch?: () => void; + }; + handleOnEventClosed: () => void; + isHostIsolationPanelOpen: boolean; + loadingEventDetails: boolean; + onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void; + timelineId: string; +} + +interface AddExceptionModalWrapperData { + alertStatus: Status; + eventId: string; + ruleId: string; + ruleName: string; +} + +export const EventDetailsFooter = React.memo( + ({ + detailsData, + expandedEvent, + handleOnEventClosed, + isHostIsolationPanelOpen, + loadingEventDetails, + onAddIsolationStatusClick, + timelineId, + }: EventDetailsFooterProps) => { + const ruleIndex = useMemo( + () => find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values, + [detailsData] + ); + + const addExceptionModalWrapperData = useMemo( + () => + [ + { category: 'signal', field: 'signal.rule.id', name: 'ruleId' }, + { category: 'signal', field: 'signal.rule.name', name: 'ruleName' }, + { category: 'signal', field: 'signal.status', name: 'alertStatus' }, + { category: '_id', field: '_id', name: 'eventId' }, + ].reduce( + (acc, curr) => ({ + ...acc, + [curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData), + }), + {} as AddExceptionModalWrapperData + ), + [detailsData] + ); + + const eventIds = useMemo(() => [expandedEvent?.eventId], [expandedEvent?.eventId]); + + const { + exceptionModalType, + onAddExceptionTypeClick, + onAddExceptionCancel, + onAddExceptionConfirm, + ruleIndices, + } = useExceptionModal({ + ruleIndex, + refetch: expandedEvent?.refetch, + timelineId, + }); + const { + closeAddEventFilterModal, + isAddEventFilterModalOpen, + onAddEventFilterClick, + } = useEventFilterModal(); + + const { alertsEcsData } = useFetchEcsAlertsData({ + alertIds: eventIds, + skip: expandedEvent?.eventId == null, + }); + + const ecsData = get(0, alertsEcsData); + return ( + <> + + + + + + + + {/* This is still wrong to do render flyout/modal inside of the flyout + We need to completely refactor the EventDetails component to be correct + */} + {exceptionModalType != null && + addExceptionModalWrapperData.ruleId != null && + addExceptionModalWrapperData.eventId != null && ( + + )} + {isAddEventFilterModalOpen && ecsData != null && ( + + )} + + ); + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 90af9d871e0c7..82e994802c650 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -5,14 +5,11 @@ * 2.0. */ -import { find, some } from 'lodash/fp'; +import { some } from 'lodash/fp'; import { EuiButtonEmpty, EuiFlyoutHeader, EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlexGroup, - EuiFlexItem, EuiSpacer, EuiTitle, EuiText, @@ -26,17 +23,16 @@ import { useTimelineEventsDetails } from '../../../containers/details'; import { TimelineTabs } from '../../../../../common/types/timeline'; import { HostIsolationPanel } from '../../../../detections/components/host_isolation'; import { EndpointIsolateSuccess } from '../../../../common/components/endpoint/host_isolation'; -import { TakeActionDropdown } from '../../../../detections/components/host_isolation/take_action_dropdown'; import { ISOLATE_HOST, UNISOLATE_HOST, } from '../../../../detections/components/host_isolation/translations'; +import { getFieldValue } from '../../../../detections/components/host_isolation/helpers'; import { ALERT_DETAILS } from './translations'; -import { useIsolationPrivileges } from '../../../../common/hooks/endpoint/use_isolate_privileges'; -import { isIsolationSupported } from '../../../../../common/endpoint/service/host_isolation/utils'; -import { endpointAlertCheck } from '../../../../common/utils/endpoint_alert_check'; import { useWithCaseDetailsRefresh } from '../../../../common/components/endpoint/host_isolation/endpoint_host_isolation_cases_context'; -import { TimelineEventsDetailsItem } from '../../../../../common'; +import { TimelineNonEcsData } from '../../../../../common'; +import { Ecs } from '../../../../../common/ecs'; +import { EventDetailsFooter } from './footer'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflow { @@ -47,29 +43,21 @@ const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflowContent { flex: 1; overflow: hidden; - padding: ${({ theme }) => `${theme.eui.paddingSizes.xs} ${theme.eui.paddingSizes.m} 50px`}; + padding: ${({ theme }) => `${theme.eui.paddingSizes.m}`}; } } `; -const getFieldValue = ( - { - category, - field, - }: { - category: string; - field: string; - }, - data: TimelineEventsDetailsItem[] | null -) => { - const currentField = find({ category, field }, data)?.values; - return currentField && currentField.length > 0 ? currentField[0] : ''; -}; - interface EventDetailsPanelProps { browserFields: BrowserFields; docValueFields: DocValueFields[]; - expandedEvent: { eventId: string; indexName: string }; + expandedEvent: { + eventId: string; + indexName: string; + ecsData?: Ecs; + nonEcsData?: TimelineNonEcsData[]; + refetch?: () => void; + }; handleOnEventClosed: () => void; isFlyoutView?: boolean; tabType: TimelineTabs; @@ -107,7 +95,6 @@ const EventDetailsPanelComponent: React.FC = ({ setIsIsolateActionSuccessBannerVisible(false); }, []); - const { isAllowed: isIsolationAllowed } = useIsolationPrivileges(); const showHostIsolationPanel = useCallback((action) => { if (action === 'isolateHost' || action === 'unisolateHost') { setIsHostIsolationPanel(true); @@ -117,30 +104,11 @@ const EventDetailsPanelComponent: React.FC = ({ const isAlert = some({ category: 'signal', field: 'signal.rule.id' }, detailsData); - const isEndpointAlert = useMemo(() => { - return endpointAlertCheck({ data: detailsData || [] }); - }, [detailsData]); - const ruleName = useMemo( () => getFieldValue({ category: 'signal', field: 'signal.rule.name' }, detailsData), [detailsData] ); - const agentId = useMemo( - () => getFieldValue({ category: 'agent', field: 'agent.id' }, detailsData), - [detailsData] - ); - - const hostOsFamily = useMemo( - () => getFieldValue({ category: 'host', field: 'host.os.name' }, detailsData), - [detailsData] - ); - - const agentVersion = useMemo( - () => getFieldValue({ category: 'agent', field: 'agent.version' }, detailsData), - [detailsData] - ); - const alertId = useMemo(() => getFieldValue({ category: '_id', field: '_id' }, detailsData), [ detailsData, ]); @@ -150,11 +118,6 @@ const EventDetailsPanelComponent: React.FC = ({ [detailsData] ); - const isolationSupported = isIsolationSupported({ - osName: hostOsFamily, - version: agentVersion, - }); - const backToAlertDetailsLink = useMemo(() => { return ( <> @@ -225,18 +188,16 @@ const EventDetailsPanelComponent: React.FC = ({ /> )} - {isIsolationAllowed && - isEndpointAlert && - isolationSupported && - isHostIsolationPanelOpen === false && ( - - - - - - - - )} + + ) : ( <> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx index 4bbcbf4e15981..2e2e912f5abfa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/action_icon_item.tsx @@ -6,7 +6,7 @@ */ import React, { MouseEvent } from 'react'; -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { EuiContextMenuItem, EuiButtonIcon, EuiToolTip, EuiText } from '@elastic/eui'; import { EventsTdContent } from '../../styles'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; @@ -20,6 +20,7 @@ interface ActionIconItemProps { isDisabled?: boolean; onClick?: (event: MouseEvent) => void; children?: React.ReactNode; + buttonType?: 'text' | 'icon'; } const ActionIconItemComponent: React.FC = ({ @@ -31,22 +32,41 @@ const ActionIconItemComponent: React.FC = ({ isDisabled = false, onClick, children, + buttonType = 'icon', }) => ( -
- - {children ?? ( - - - - )} - -
+ <> + {buttonType === 'icon' && ( +
+ + {children ?? ( + + + + )} + +
+ )} + {buttonType === 'text' && ( + + + {content} + + + )} + ); ActionIconItemComponent.displayName = 'ActionIconItemComponent'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index b8840a75cc9b4..302aead337ed7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -185,6 +185,7 @@ const StatefulEventComponent: React.FC = ({ params: { eventId, indexName, + refetch, }, }; @@ -199,7 +200,7 @@ const StatefulEventComponent: React.FC = ({ if (timelineId === TimelineId.active && tabType === TimelineTabs.query) { activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail }); } - }, [dispatch, event._id, event._index, tabType, timelineId]); + }, [dispatch, event._id, event._index, refetch, tabType, timelineId]); const associateNote = useCallback( (noteId: string) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 19059b5fb4599..87fb4ee762ab0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -68,6 +68,7 @@ jest.mock('../../../../common/lib/helpers/scheduler', () => ({ describe('Body', () => { const mount = useMountAppended(); + const mockRefetch = jest.fn(); const props: StatefulBodyProps = { activePage: 0, browserFields: mockBrowserFields, @@ -80,7 +81,7 @@ describe('Body', () => { isSelectAllChecked: false, loadingEventIds: [], pinnedEventIds: {}, - refetch: jest.fn(), + refetch: mockRefetch, renderCellValue: DefaultCellRenderer, rowRenderers: defaultRowRenderers, selectedEventIds: {}, @@ -253,6 +254,7 @@ describe('Body', () => { params: { eventId: '1', indexName: undefined, + refetch: mockRefetch, }, tabType: 'query', timelineId: 'timeline-test', @@ -277,6 +279,7 @@ describe('Body', () => { params: { eventId: '1', indexName: undefined, + refetch: mockRefetch, }, tabType: 'pinned', timelineId: 'timeline-test', @@ -301,6 +304,7 @@ describe('Body', () => { params: { eventId: '1', indexName: undefined, + refetch: mockRefetch, }, tabType: 'notes', timelineId: 'timeline-test', diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 6b59d9780a513..e6b21ea96a266 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -56,6 +56,8 @@ import { SignalHit } from '../../../../plugins/security_solution/server/lib/dete import { ActionResult, FindActionResult } from '../../../../plugins/actions/server/types'; import { User } from './authentication/types'; import { superUser } from './authentication/users'; +import { ESCasesConfigureAttributes } from '../../../../plugins/cases/server/services/configure/types'; +import { ESCaseAttributes } from '../../../../plugins/cases/server/services/cases/types'; function toArray(input: T | T[]): T[] { if (Array.isArray(input)) { @@ -605,6 +607,49 @@ export const getConnectorMappingsFromES = async ({ es }: { es: KibanaClient }) = return mappings; }; +interface ConfigureSavedObject { + 'cases-configure': ESCasesConfigureAttributes; +} + +/** + * Returns configure saved objects from Elasticsearch directly. + */ +export const getConfigureSavedObjectsFromES = async ({ es }: { es: KibanaClient }) => { + const configure: ApiResponse> = await es.search({ + index: '.kibana', + body: { + query: { + term: { + type: { + value: 'cases-configure', + }, + }, + }, + }, + }); + + return configure; +}; + +export const getCaseSavedObjectsFromES = async ({ es }: { es: KibanaClient }) => { + const configure: ApiResponse< + estypes.SearchResponse<{ cases: ESCaseAttributes }> + > = await es.search({ + index: '.kibana', + body: { + query: { + term: { + type: { + value: 'cases', + }, + }, + }, + }, + }); + + return configure; +}; + export const createCaseWithConnector = async ({ supertest, configureReq = {}, diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts index 941b71fb925db..f21a0ab460424 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts @@ -11,7 +11,7 @@ import { CASES_URL, SECURITY_SOLUTION_OWNER, } from '../../../../../../plugins/cases/common/constants'; -import { getCase } from '../../../../common/lib/utils'; +import { getCase, getCaseSavedObjectsFromES } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { @@ -121,13 +121,90 @@ export default function createGetTests({ getService }: FtrProviderContext) { await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); }); - it('adds the owner field', async () => { - const theCase = await getCase({ - supertest, - caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + describe('owner field', () => { + it('adds the owner field', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.owner).to.be(SECURITY_SOLUTION_OWNER); + }); + }); + + describe('migrating connector id to a reference', () => { + const es = getService('es'); + + it('preserves the connector id after migration in the API response', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.connector.id).to.be('d68508f0-cf9d-11eb-a603-13e7747d215c'); + }); + + it('preserves the connector fields after migration in the API response', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.connector).to.eql({ + fields: { + issueType: '10002', + parent: null, + priority: null, + }, + id: 'd68508f0-cf9d-11eb-a603-13e7747d215c', + name: 'Test Jira', + type: '.jira', + }); }); - expect(theCase.owner).to.be(SECURITY_SOLUTION_OWNER); + it('removes the connector id field in the saved object', async () => { + const casesFromES = await getCaseSavedObjectsFromES({ es }); + expect(casesFromES.body.hits.hits[0]._source?.cases.connector).to.not.have.property('id'); + }); + + it('preserves the external_service.connector_id after migration in the API response', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.external_service?.connector_id).to.be( + 'd68508f0-cf9d-11eb-a603-13e7747d215c' + ); + }); + + it('preserves the external_service fields after migration in the API response', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.external_service).to.eql({ + connector_id: 'd68508f0-cf9d-11eb-a603-13e7747d215c', + connector_name: 'Test Jira', + external_id: '10106', + external_title: 'TPN-99', + external_url: 'https://cases-testing.atlassian.net/browse/TPN-99', + pushed_at: '2021-06-17T18:57:45.524Z', + pushed_by: { + email: null, + full_name: 'j@j.com', + username: '711621466', + }, + }); + }); + + it('removes the connector_id field in the saved object', async () => { + const casesFromES = await getCaseSavedObjectsFromES({ es }); + expect( + casesFromES.body.hits.hits[0]._source?.cases.external_service + ).to.not.have.property('id'); + }); }); }); }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts index 67eb23a43f397..79d5a46fa8bdd 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts @@ -11,7 +11,11 @@ import { CASE_CONFIGURE_URL, SECURITY_SOLUTION_OWNER, } from '../../../../../../plugins/cases/common/constants'; -import { getConfiguration, getConnectorMappingsFromES } from '../../../../common/lib/utils'; +import { + getConfiguration, + getConfigureSavedObjectsFromES, + getConnectorMappingsFromES, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default function ({ getService }: FtrProviderContext) { @@ -57,23 +61,57 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); }); - it('adds the owner field', async () => { - const configuration = await getConfiguration({ - supertest, - query: { owner: SECURITY_SOLUTION_OWNER }, + describe('owner field', () => { + it('adds the owner field', async () => { + const configuration = await getConfiguration({ + supertest, + query: { owner: SECURITY_SOLUTION_OWNER }, + }); + + expect(configuration[0].owner).to.be(SECURITY_SOLUTION_OWNER); }); - expect(configuration[0].owner).to.be(SECURITY_SOLUTION_OWNER); + it('adds the owner field to the connector mapping', async () => { + // We don't get the owner field back from the mappings when we retrieve the configuration so the only way to + // check that the migration worked is by checking the saved object stored in Elasticsearch directly + const mappings = await getConnectorMappingsFromES({ es }); + expect(mappings.body.hits.hits.length).to.be(1); + expect(mappings.body.hits.hits[0]._source?.['cases-connector-mappings'].owner).to.eql( + SECURITY_SOLUTION_OWNER + ); + }); }); - it('adds the owner field to the connector mapping', async () => { - // We don't get the owner field back from the mappings when we retrieve the configuration so the only way to - // check that the migration worked is by checking the saved object stored in Elasticsearch directly - const mappings = await getConnectorMappingsFromES({ es }); - expect(mappings.body.hits.hits.length).to.be(1); - expect(mappings.body.hits.hits[0]._source?.['cases-connector-mappings'].owner).to.eql( - SECURITY_SOLUTION_OWNER - ); + describe('migrating connector id to a reference', () => { + it('preserves the connector id after migration in the API response', async () => { + const configuration = await getConfiguration({ + supertest, + query: { owner: SECURITY_SOLUTION_OWNER }, + }); + + expect(configuration[0].connector.id).to.be('d68508f0-cf9d-11eb-a603-13e7747d215c'); + }); + + it('preserves the connector fields after migration in the API response', async () => { + const configuration = await getConfiguration({ + supertest, + query: { owner: SECURITY_SOLUTION_OWNER }, + }); + + expect(configuration[0].connector).to.eql({ + fields: null, + id: 'd68508f0-cf9d-11eb-a603-13e7747d215c', + name: 'Test Jira', + type: '.jira', + }); + }); + + it('removes the connector id field in the saved object', async () => { + const configurationFromES = await getConfigureSavedObjectsFromES({ es }); + expect( + configurationFromES.body.hits.hits[0]._source?.['cases-configure'].connector + ).to.not.have.property('id'); + }); }); }); }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts index 374053dd3b8b7..94fe494fc7cc4 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts @@ -31,6 +31,7 @@ import { createConnector, getServiceNowConnector, getConnectorMappingsFromES, + getCase, } from '../../../../common/lib/utils'; import { ExternalServiceSimulator, @@ -102,6 +103,72 @@ export default ({ getService }: FtrProviderContext): void => { ).to.equal(true); }); + it('preserves the connector.id after pushing a case', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + const theCase = await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: connector.id, + }); + + expect(theCase.connector.id).to.eql(connector.id); + }); + + it('preserves the external_service.connector_id after updating the connector', async () => { + const { postedCase, connector: pushConnector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + + const theCaseAfterPush = await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: pushConnector.id, + }); + + const newConnector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + }); + + actionsRemover.add('default', newConnector.id, 'action', 'actions'); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: theCaseAfterPush.version, + connector: { + id: newConnector.id, + name: newConnector.name, + type: newConnector.connector_type_id, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, + } as CaseConnector, + }, + ], + }, + }); + + const theCaseAfterUpdate = await getCase({ supertest, caseId: postedCase.id }); + expect(theCaseAfterUpdate.connector.id).to.eql(newConnector.id); + expect(theCaseAfterUpdate.external_service?.connector_id).to.eql(pushConnector.id); + }); + it('should create the mappings when pushing a case', async () => { // create a connector but not a configuration so that the mapping will not be present const connector = await createConnector({ diff --git a/x-pack/test/functional/es_archives/cases/migrations/7.13.2/data.json.gz b/x-pack/test/functional/es_archives/cases/migrations/7.13.2/data.json.gz index c86af3f7d2fba..26a782f32508b 100644 Binary files a/x-pack/test/functional/es_archives/cases/migrations/7.13.2/data.json.gz and b/x-pack/test/functional/es_archives/cases/migrations/7.13.2/data.json.gz differ diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/bwc_existing_indexes.ts b/x-pack/test/reporting_api_integration/reporting_and_security/bwc_existing_indexes.ts new file mode 100644 index 0000000000000..0da51901f9086 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/bwc_existing_indexes.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +/** + * This file tests the situation when a reporting index spans releases. By default reporting indexes are created + * on a weekly basis, but this is configurable so it is possible a user has this set to yearly. In that event, it + * is possible report data is getting posted to an index that was created by a very old version. We don't have a + * reporting index migration plan, so this test is important to ensure BWC, or that in the event we decide to make + * a major change in a major release, we handle it properly. + */ + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const reportingAPI = getService('reportingAPI'); + + describe('BWC report generation into existing indexes', () => { + let cleanupIndexAlias: () => Promise; + + describe('existing 6_2 index', () => { + before('load data and add index alias', async () => { + await reportingAPI.deleteAllReports(); + // data to report on + await esArchiver.load('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.load('test/functional/fixtures/es_archiver/discover'); // includes index pattern for logstash_functional + // archive with reporting index mappings v6.2 + await esArchiver.load('x-pack/test/functional/es_archives/reporting/bwc/6_2'); + + // The index name in the reporting/bwc/6_2 archive. + const ARCHIVED_REPORTING_INDEX = '.reporting-2018.03.11'; + // causes reporting to assume the v6.2 index is the one to use for new jobs posted + cleanupIndexAlias = await reportingAPI.coerceReportsIntoExistingIndex( + ARCHIVED_REPORTING_INDEX + ); + }); + + after('remove index alias', async () => { + await esArchiver.load('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.load('test/functional/fixtures/es_archiver/discover'); + + await cleanupIndexAlias(); + await esArchiver.unload('x-pack/test/functional/es_archives/reporting/bwc/6_2'); + }); + + it('single job posted can complete in an index created with an older version', async () => { + const reportPaths = []; + reportPaths.push( + await reportingAPI.postJob( + '/api/reporting/generate/csv_searchsource?jobParams=%28browserTimezone%3AAmerica%2FPhoenix%2Ccolumns%3A%21%28%29%2CobjectType%3Asearch%2CsearchSource%3A%28fields%3A%21%28%28field%3A%27%2A%27%2Cinclude_unmapped%3Atrue%29%29%2Cfilter%3A%21%28%28meta%3A%28index%3A%27logstash-%2A%27%2Cparams%3A%28%29%29%2Crange%3A%28%27%40timestamp%27%3A%28format%3Astrict_date_optional_time%2Cgte%3A%272015-09-20T16%3A00%3A56.290Z%27%2Clte%3A%272015-09-21T10%3A37%3A45.066Z%27%29%29%29%29%2Cindex%3A%27logstash-%2A%27%2Cparent%3A%28filter%3A%21%28%29%2Cindex%3A%27logstash-%2A%27%2Cquery%3A%28language%3Akuery%2Cquery%3A%27%27%29%29%2Csort%3A%21%28%28%27%40timestamp%27%3Adesc%29%29%2CtrackTotalHits%3A%21t%2Cversion%3A%21t%29%2Ctitle%3A%27Discover%20search%20%5B2021-07-30T11%3A47%3A03.731-07%3A00%5D%27%29' + ) + ); + await reportingAPI.expectAllJobsToFinishSuccessfully(reportPaths); + }).timeout(1540000); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts index d279081b5320c..2996f49857ddd 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts @@ -20,6 +20,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await reportingAPI.createTestReportingUser(); }); + loadTestFile(require.resolve('./bwc_existing_indexes')); loadTestFile(require.resolve('./security_roles_privileges')); loadTestFile(require.resolve('./download_csv_dashboard')); loadTestFile(require.resolve('./generate_csv_discover')); diff --git a/x-pack/test/reporting_api_integration/services/usage.ts b/x-pack/test/reporting_api_integration/services/usage.ts index 4e8d464467191..0a5e0c8761eac 100644 --- a/x-pack/test/reporting_api_integration/services/usage.ts +++ b/x-pack/test/reporting_api_integration/services/usage.ts @@ -6,6 +6,7 @@ */ import expect from '@kbn/expect'; +import { indexTimestamp } from '../../../plugins/reporting/server/lib/store/index_timestamp'; import { FtrProviderContext } from '../ftr_provider_context'; interface PDFAppCounts { @@ -37,6 +38,7 @@ export interface UsageStats { export function createUsageServices({ getService }: FtrProviderContext) { const log = getService('log'); + const esSupertest = getService('esSupertest'); const supertest = getService('supertest'); return { @@ -67,6 +69,42 @@ export function createUsageServices({ getService }: FtrProviderContext) { expect(statusCode).to.be(200); }, + /** + * + * @return {Promise} A function to call to clean up the index alias that was added. + */ + async coerceReportsIntoExistingIndex(indexName: string) { + log.debug(`ReportingAPI.coerceReportsIntoExistingIndex(${indexName})`); + + // Adding an index alias coerces the report to be generated on an existing index which means any new + // index schema won't be applied. This is important if a point release updated the schema. Reports may still + // be inserted into an existing index before the new schema is applied. + const timestampForIndex = indexTimestamp('week', '.'); + await esSupertest + .post('/_aliases') + .send({ + actions: [ + { + add: { index: indexName, alias: `.reporting-${timestampForIndex}` }, + }, + ], + }) + .expect(200); + + return async () => { + await esSupertest + .post('/_aliases') + .send({ + actions: [ + { + remove: { index: indexName, alias: `.reporting-${timestampForIndex}` }, + }, + ], + }) + .expect(200); + }; + }, + async expectAllJobsToFinishSuccessfully(jobPaths: string[]) { await Promise.all( jobPaths.map(async (path) => {