diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 2a8795d77842a..7c197fccc27ca 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -8,10 +8,20 @@ "targetNamespace", "targetType" ], - "config": ["buildNum"], - "config-global": ["buildNum"], - "url": ["accessDate", "createDate", "slug"], - "usage-counters": ["domainId"], + "config": [ + "buildNum" + ], + "config-global": [ + "buildNum" + ], + "url": [ + "accessDate", + "createDate", + "slug" + ], + "usage-counters": [ + "domainId" + ], "task": [ "attempts", "enabled", @@ -25,15 +35,33 @@ "status", "taskType" ], - "guided-onboarding-guide-state": ["guideId", "isActive"], + "guided-onboarding-guide-state": [ + "guideId", + "isActive" + ], "guided-onboarding-plugin-state": [], - "ui-metric": ["count"], + "ui-metric": [ + "count" + ], "application_usage_totals": [], - "application_usage_daily": ["timestamp"], - "event_loop_delays_daily": ["lastUpdatedAt"], - "index-pattern": ["name", "title", "type"], - "sample-data-telemetry": ["installCount", "unInstallCount"], - "space": ["name"], + "application_usage_daily": [ + "timestamp" + ], + "event_loop_delays_daily": [ + "lastUpdatedAt" + ], + "index-pattern": [ + "name", + "title", + "type" + ], + "sample-data-telemetry": [ + "installCount", + "unInstallCount" + ], + "space": [ + "name" + ], "spaces-usage-stats": [], "exception-list-agnostic": [ "_tags", @@ -127,17 +155,44 @@ "size", "user" ], - "fileShare": ["created", "name", "token", "valid_until"], - "action": ["actionTypeId", "name"], + "fileShare": [ + "created", + "name", + "token", + "valid_until" + ], + "action": [ + "actionTypeId", + "name" + ], "action_task_params": [], - "connector_token": ["connectorId", "tokenType"], - "query": ["description", "title"], + "connector_token": [ + "connectorId", + "tokenType" + ], + "query": [ + "description", + "title" + ], "kql-telemetry": [], - "search-session": ["created", "realmName", "realmType", "sessionId", "username"], + "search-session": [ + "created", + "realmName", + "realmType", + "sessionId", + "username" + ], "search-telemetry": [], - "file-upload-usage-collection-telemetry": ["file_upload", "file_upload.index_creation_count"], + "file-upload-usage-collection-telemetry": [ + "file_upload", + "file_upload.index_creation_count" + ], "apm-indices": [], - "tag": ["color", "description", "name"], + "tag": [ + "color", + "description", + "name" + ], "alert": [ "actions", "actions.actionRef", @@ -206,9 +261,17 @@ "updatedAt", "updatedBy" ], - "api_key_pending_invalidation": ["apiKeyId", "createdAt"], - "rules-settings": ["flapping"], - "maintenance-window": ["enabled", "events"], + "api_key_pending_invalidation": [ + "apiKeyId", + "createdAt" + ], + "rules-settings": [ + "flapping" + ], + "maintenance-window": [ + "enabled", + "events" + ], "graph-workspace": [ "description", "kibanaSavedObjectMeta", @@ -220,12 +283,39 @@ "version", "wsState" ], - "search": ["description", "title"], - "visualization": ["description", "kibanaSavedObjectMeta", "title", "version"], - "canvas-element": ["@created", "@timestamp", "content", "help", "image", "name"], - "canvas-workpad": ["@created", "@timestamp", "name"], - "canvas-workpad-template": ["help", "name", "tags", "template_key"], - "event-annotation-group": ["description", "title"], + "search": [ + "description", + "title" + ], + "visualization": [ + "description", + "kibanaSavedObjectMeta", + "title", + "version" + ], + "canvas-element": [ + "@created", + "@timestamp", + "content", + "help", + "image", + "name" + ], + "canvas-workpad": [ + "@created", + "@timestamp", + "name" + ], + "canvas-workpad-template": [ + "help", + "name", + "tags", + "template_key" + ], + "event-annotation-group": [ + "description", + "title" + ], "dashboard": [ "controlGroupInput", "controlGroupInput.chainingSystem", @@ -249,9 +339,23 @@ "title", "version" ], - "links": ["description", "links", "title"], - "lens": ["description", "state", "title", "visualizationType"], - "lens-ui-telemetry": ["count", "date", "name", "type"], + "links": [ + "description", + "links", + "title" + ], + "lens": [ + "description", + "state", + "title", + "visualizationType" + ], + "lens-ui-telemetry": [ + "count", + "date", + "name", + "type" + ], "map": [ "bounds", "description", @@ -276,8 +380,14 @@ "type", "updated_at" ], - "cases-configure": ["closure_type", "created_at", "owner"], - "cases-connector-mappings": ["owner"], + "cases-configure": [ + "closure_type", + "created_at", + "owner" + ], + "cases-connector-mappings": [ + "owner" + ], "cases": [ "assignees", "assignees.uid", @@ -351,7 +461,9 @@ "type" ], "cases-telemetry": [], - "infrastructure-monitoring-log-view": ["name"], + "infrastructure-monitoring-log-view": [ + "name" + ], "metrics-data-source": [], "ingest_manager_settings": [ "fleet_server_hosts", @@ -505,9 +617,23 @@ "package_name", "package_version" ], - "fleet-preconfiguration-deletion-record": ["id"], - "ingest-download-sources": ["host", "is_default", "name", "proxy_id", "source_id"], - "fleet-fleet-server-host": ["host_urls", "is_default", "is_preconfigured", "name", "proxy_id"], + "fleet-preconfiguration-deletion-record": [ + "id" + ], + "ingest-download-sources": [ + "host", + "is_default", + "name", + "proxy_id", + "source_id" + ], + "fleet-fleet-server-host": [ + "host_urls", + "is_default", + "is_preconfigured", + "name", + "proxy_id" + ], "fleet-proxy": [ "certificate", "certificate_authorities", @@ -518,8 +644,14 @@ "url" ], "fleet-message-signing-keys": [], - "fleet-uninstall-tokens": ["policy_id", "token_plain"], - "osquery-manager-usage-metric": ["count", "errors"], + "fleet-uninstall-tokens": [ + "policy_id", + "token_plain" + ], + "osquery-manager-usage-metric": [ + "count", + "errors" + ], "osquery-saved-query": [ "created_at", "created_by", @@ -593,9 +725,22 @@ "version" ], "threshold-explorer-view": [], - "observability-onboarding-state": ["progress", "state", "type"], - "ml-job": ["datafeed_id", "job_id", "type"], - "ml-trained-model": ["job", "job.create_time", "job.job_id", "model_id"], + "observability-onboarding-state": [ + "progress", + "state", + "type" + ], + "ml-job": [ + "datafeed_id", + "job_id", + "type" + ], + "ml-trained-model": [ + "job", + "job.create_time", + "job.job_id", + "model_id" + ], "ml-module": [ "datafeeds", "defaultIndexPattern", @@ -636,19 +781,41 @@ "type", "urls" ], - "uptime-synthetics-api-key": ["apiKey"], + "uptime-synthetics-api-key": [ + "apiKey" + ], "synthetics-param": [], "infrastructure-ui-source": [], "inventory-view": [], "metrics-explorer-view": [], - "upgrade-assistant-reindex-operation": ["indexName", "status"], - "upgrade-assistant-ml-upgrade-operation": ["snapshotId"], - "monitoring-telemetry": ["reportedClusterUuids"], + "upgrade-assistant-reindex-operation": [ + "indexName", + "status" + ], + "upgrade-assistant-ml-upgrade-operation": [ + "snapshotId" + ], + "monitoring-telemetry": [ + "reportedClusterUuids" + ], "enterprise_search_telemetry": [], "app_search_telemetry": [], "workplace_search_telemetry": [], - "siem-ui-timeline-note": ["created", "createdBy", "eventId", "note", "updated", "updatedBy"], - "siem-ui-timeline-pinned-event": ["created", "createdBy", "eventId", "updated", "updatedBy"], + "siem-ui-timeline-note": [ + "created", + "createdBy", + "eventId", + "note", + "updated", + "updatedBy" + ], + "siem-ui-timeline-pinned-event": [ + "created", + "createdBy", + "eventId", + "updated", + "updatedBy" + ], "siem-detection-engine-rule-actions": [ "actions", "actions.actionRef", @@ -660,7 +827,10 @@ "ruleAlertId", "ruleThrottle" ], - "security-rule": ["rule_id", "version"], + "security-rule": [ + "rule_id", + "version" + ], "siem-ui-timeline": [ "columns", "columns.aggregatable", @@ -760,8 +930,15 @@ "updated", "updatedBy" ], - "endpoint:user-artifact-manifest": ["artifacts", "schemaVersion"], - "security-solution-signals-migration": ["sourceIndex", "updated", "version"], + "endpoint:user-artifact-manifest": [ + "artifacts", + "schemaVersion" + ], + "security-solution-signals-migration": [ + "sourceIndex", + "updated", + "version" + ], "risk-engine-configuration": [ "dataViewId", "enabled", @@ -773,16 +950,35 @@ "range.end", "range.start" ], - "policy-settings-protection-updates-note": ["note"], + "policy-settings-protection-updates-note": [ + "note" + ], "apm-telemetry": [], - "apm-server-schema": ["schemaJson"], - "apm-service-group": ["color", "description", "groupName", "kuery"], + "apm-server-schema": [ + "schemaJson" + ], + "apm-service-group": [ + "color", + "description", + "groupName", + "kuery" + ], "apm-custom-dashboards": [ "dashboardSavedObjectId", "kuery", "serviceEnvironmentFilterEnabled", "serviceNameFilterEnabled" ], - "cloud-security-posture-settings": ["rules"], - "cases-oracle": ["cases", "cases.id", "counter", "createdAt", "rules", "rules.id", "updatedAt"] + "cloud-security-posture-settings": [ + "rules" + ], + "cases-oracle": [ + "cases", + "cases.id", + "counter", + "createdAt", + "rules", + "rules.id", + "updatedAt" + ] } diff --git a/x-pack/plugins/cases/server/client/cases/update.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts similarity index 97% rename from x-pack/plugins/cases/server/client/cases/update.test.ts rename to x-pack/plugins/cases/server/client/cases/bulk_update.test.ts index 29bce4636c3f3..c01e5e29a0138 100644 --- a/x-pack/plugins/cases/server/client/cases/update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts @@ -19,7 +19,7 @@ import { } from '../../../common/constants'; import { mockCases } from '../../mocks'; import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; -import { update } from './update'; +import { bulkUpdate } from './bulk_update'; describe('update', () => { const cases = { @@ -55,7 +55,7 @@ describe('update', () => { }); it('notifies an assignee', async () => { - await update(cases, clientArgs, casesClientMock); + await bulkUpdate(cases, clientArgs, casesClientMock); expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ { @@ -72,7 +72,7 @@ describe('update', () => { expect.assertions(2); await expect( - update( + bulkUpdate( { cases: [ { @@ -104,7 +104,7 @@ describe('update', () => { ], }); - await expect(update(cases, clientArgs, casesClientMock)).rejects.toThrow( + await expect(bulkUpdate(cases, clientArgs, casesClientMock)).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: All update fields are identical to current version.' ); @@ -130,7 +130,7 @@ describe('update', () => { ], }); - await update( + await bulkUpdate( { cases: [ { @@ -172,7 +172,7 @@ describe('update', () => { saved_objects: [{ ...mockCases[0], attributes: { assignees: [{ uid: '1' }] } }], }); - await update( + await bulkUpdate( { cases: [ { @@ -211,7 +211,7 @@ describe('update', () => { ], }); - await update( + await bulkUpdate( { cases: [ { @@ -249,7 +249,7 @@ describe('update', () => { ], }); - await update( + await bulkUpdate( { cases: [ { @@ -273,7 +273,7 @@ describe('update', () => { it('should throw an error when an invalid field is included in the request payload', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -297,7 +297,7 @@ describe('update', () => { const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foo' }); await expect( - update( + bulkUpdate( { cases: [ { @@ -337,7 +337,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -355,7 +355,7 @@ describe('update', () => { it('does not update the category if the length is too long', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -375,7 +375,7 @@ describe('update', () => { it('throws error if category is just an empty string', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -395,7 +395,7 @@ describe('update', () => { it('throws error if category is a string with empty characters', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -414,7 +414,7 @@ describe('update', () => { }); it('should trim category', async () => { - await update( + await bulkUpdate( { cases: [ { @@ -471,7 +471,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -489,7 +489,7 @@ describe('update', () => { it('throws error if the title is too long', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -510,7 +510,7 @@ describe('update', () => { it('throws error if title is just an empty string', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -530,7 +530,7 @@ describe('update', () => { it('throws error if title is a string with empty characters', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -549,7 +549,7 @@ describe('update', () => { }); it('should trim title', async () => { - await update( + await bulkUpdate( { cases: [ { @@ -606,7 +606,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -628,7 +628,7 @@ describe('update', () => { .toString(); await expect( - update( + bulkUpdate( { cases: [ { @@ -648,7 +648,7 @@ describe('update', () => { it('throws error if description is just an empty string', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -668,7 +668,7 @@ describe('update', () => { it('throws error if description is a string with empty characters', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -687,7 +687,7 @@ describe('update', () => { }); it('should trim description', async () => { - await update( + await bulkUpdate( { cases: [ { @@ -888,7 +888,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -910,7 +910,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -930,7 +930,7 @@ describe('update', () => { const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foo'); await expect( - update( + bulkUpdate( { cases: [ { @@ -954,7 +954,7 @@ describe('update', () => { .toString(); await expect( - update( + bulkUpdate( { cases: [ { @@ -974,7 +974,7 @@ describe('update', () => { it('throws error if tag is empty string', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -994,7 +994,7 @@ describe('update', () => { it('throws error if tag is a string with empty characters', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -1013,7 +1013,7 @@ describe('update', () => { }); it('should trim tags', async () => { - await update( + await bulkUpdate( { cases: [ { @@ -1104,7 +1104,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1154,7 +1154,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1205,7 +1205,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1225,7 +1225,7 @@ describe('update', () => { it('throws with duplicated customFields keys', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -1256,7 +1256,7 @@ describe('update', () => { it('throws when customFields keys are not present in configuration', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -1287,7 +1287,7 @@ describe('update', () => { it('throws error when custom fields are missing', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -1313,7 +1313,7 @@ describe('update', () => { it('throws when the customField types dont match the configuration', async () => { await expect( - update( + bulkUpdate( { cases: [ { @@ -1355,7 +1355,7 @@ describe('update', () => { it(`throws an error when trying to update more than ${MAX_CASES_TO_UPDATE} cases`, async () => { await expect( - update( + bulkUpdate( { cases: Array(MAX_CASES_TO_UPDATE + 1).fill({ id: mockCases[0].id, @@ -1373,7 +1373,7 @@ describe('update', () => { it('throws an error when trying to update zero cases', async () => { await expect( - update( + bulkUpdate( { cases: [], }, @@ -1416,7 +1416,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1445,7 +1445,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { @@ -1478,7 +1478,7 @@ describe('update', () => { }); await expect( - update( + bulkUpdate( { cases: [ { diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.ts similarity index 99% rename from x-pack/plugins/cases/server/client/cases/update.ts rename to x-pack/plugins/cases/server/client/cases/bulk_update.ts index 529676d2b8d7b..84e10358d30b5 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.ts @@ -309,7 +309,7 @@ export interface UpdateRequestWithOriginalCase { * * @ignore */ -export const update = async ( +export const bulkUpdate = async ( cases: CasesPatchRequest, clientArgs: CasesClientArgs, casesClient: CasesClient diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts index 2c19df9b4c0f1..f209263aebd9c 100644 --- a/x-pack/plugins/cases/server/client/cases/client.ts +++ b/x-pack/plugins/cases/server/client/cases/client.ts @@ -32,7 +32,7 @@ import type { CasesByAlertIDParams, GetParams } from './get'; import { get, resolve, getCasesByAlertID, getReporters, getTags, getCategories } from './get'; import type { PushParams } from './push'; import { push } from './push'; -import { update } from './update'; +import { bulkUpdate } from './bulk_update'; import { bulkCreate } from './bulk_create'; /** @@ -73,7 +73,7 @@ export interface CasesSubClient { /** * Update the specified cases with the passed in values. */ - update(cases: CasesPatchRequest): Promise; + bulkUpdate(cases: CasesPatchRequest): Promise; /** * Delete a case and all its comments. * @@ -116,7 +116,7 @@ export const createCasesSubClient = ( resolve: (params: GetParams) => resolve(params, clientArgs), bulkGet: (params) => bulkGet(params, clientArgs), push: (params: PushParams) => push(params, clientArgs, casesClient), - update: (cases: CasesPatchRequest) => update(cases, clientArgs, casesClient), + bulkUpdate: (cases: CasesPatchRequest) => bulkUpdate(cases, clientArgs, casesClient), delete: (ids: string[]) => deleteCases(ids, clientArgs), getTags: (params: AllTagsFindRequest) => getTags(params, clientArgs), getCategories: (params: AllCategoriesFindRequest) => getCategories(params, clientArgs), diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index f9d02c094e425..e86db5e23e919 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -53,7 +53,7 @@ const createCasesSubClientMock = (): CasesSubClientMock => { get: jest.fn(), bulkGet: jest.fn(), push: jest.fn(), - update: jest.fn(), + bulkUpdate: jest.fn(), delete: jest.fn(), getTags: jest.fn(), getReporters: jest.fn(), diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_connector.test.ts b/x-pack/plugins/cases/server/connectors/cases/cases_connector.test.ts index 9bc09210c6362..19dec436d2511 100644 --- a/x-pack/plugins/cases/server/connectors/cases/cases_connector.test.ts +++ b/x-pack/plugins/cases/server/connectors/cases/cases_connector.test.ts @@ -18,6 +18,7 @@ import { CasesService } from './cases_service'; import { createCasesClientMock } from '../../client/mocks'; import { mockCases } from '../../mocks'; import type { Cases } from '../../../common'; +import { CaseStatuses } from '@kbn/cases-components'; jest.mock('./cases_oracle_service'); jest.mock('./cases_service'); @@ -60,6 +61,7 @@ describe('CasesConnector', () => { const owner = 'cases'; const timeWindow = '7d'; + const reopenClosedCases = false; const groupedAlertsWithOracleKey = [ { @@ -146,6 +148,8 @@ describe('CasesConnector', () => { id: groupedAlertsWithOracleKey[2].oracleKey, grouping: groupedAlertsWithOracleKey[2].grouping, version: 'so-version-2', + createdAt: '2023-11-13T10:23:42.769Z', + updatedAt: '2023-11-13T10:23:42.769Z', }, ]), bulkUpdateRecord: mockBulkUpdateRecord.mockResolvedValue([]), @@ -162,6 +166,7 @@ describe('CasesConnector', () => { casesClientMock.cases.bulkGet.mockResolvedValue({ cases, errors: [] }); casesClientMock.cases.bulkCreate.mockResolvedValue({ cases: [] }); + casesClientMock.cases.bulkUpdate.mockResolvedValue([]); getCasesClient.mockReturnValue(casesClientMock); @@ -183,7 +188,14 @@ describe('CasesConnector', () => { describe('run', () => { describe('Oracle records', () => { it('generates the oracle keys correctly with grouping by one field', async () => { - await connector.run({ alerts, groupingBy: ['host.name'], owner, rule, timeWindow }); + await connector.run({ + alerts, + groupingBy: ['host.name'], + owner, + rule, + timeWindow, + reopenClosedCases, + }); expect(mockGetRecordId).toHaveBeenCalledTimes(2); @@ -203,7 +215,7 @@ describe('CasesConnector', () => { }); it('generates the oracle keys correct with grouping by multiple fields', async () => { - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(mockGetRecordId).toHaveBeenCalledTimes(3); @@ -218,7 +230,7 @@ describe('CasesConnector', () => { }); it('gets the oracle records correctly', async () => { - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(mockBulkGetRecords).toHaveBeenCalledWith([ groupedAlertsWithOracleKey[0].oracleKey, @@ -228,7 +240,7 @@ describe('CasesConnector', () => { }); it('created the non found oracle records correctly', async () => { - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(mockBulkCreateRecord).toHaveBeenCalledWith([ { @@ -245,7 +257,7 @@ describe('CasesConnector', () => { it('does not create oracle records if there are no 404 errors', async () => { mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(mockBulkCreateRecord).not.toHaveBeenCalled(); }); @@ -261,7 +273,7 @@ describe('CasesConnector', () => { }, ]); - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); /** * TODO: Change it to: expect(mockBulkCreateRecord).not.toHaveBeenCalled(); @@ -271,14 +283,14 @@ describe('CasesConnector', () => { it('does not increase the counter if the time window has not passed', async () => { mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(mockBulkUpdateRecord).not.toHaveBeenCalled(); }); it('updates the counter correctly if the time window has passed', async () => { dateMathMock.parse.mockImplementation(() => moment('2023-11-10T10:23:42.769Z')); - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(mockBulkUpdateRecord).toHaveBeenCalledWith([ { payload: { counter: 2 }, recordId: 'so-oracle-record-0', version: 'so-version-0' }, @@ -294,6 +306,8 @@ describe('CasesConnector', () => { id: groupedAlertsWithOracleKey[2].oracleKey, grouping: groupedAlertsWithOracleKey[2].grouping, version: 'so-version-2', + createdAt: '2023-11-13T10:23:42.769Z', + updatedAt: '2023-11-13T10:23:42.769Z', }, // Returning errors to verify that the code does not return them { @@ -317,7 +331,7 @@ describe('CasesConnector', () => { }, ]); - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); // 1. Get all records expect(mockBulkGetRecords).toHaveBeenCalledWith([ @@ -347,7 +361,7 @@ describe('CasesConnector', () => { describe('Cases', () => { it('generates the case ids correctly', async () => { - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(mockGetCaseId).toHaveBeenCalledTimes(3); @@ -367,41 +381,41 @@ describe('CasesConnector', () => { mockBulkUpdateRecord.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(mockGetCaseId).toBeCalledTimes(3); /** - * Oracle record index: 1 - * Should not update the counter + * Oracle record index: 0 + * Should update the counter */ expect(mockGetCaseId).nthCalledWith(1, { - counter: 1, - grouping: { 'dest.ip': '0.0.0.1', 'host.name': 'B' }, + counter: 2, + grouping: { 'dest.ip': '0.0.0.1', 'host.name': 'A' }, owner: 'cases', ruleId: 'rule-test-id', spaceId: 'default', }); /** - * Oracle record index: 3 - * Not found. Created. + * Oracle record index: 1 + * Should not update the counter */ expect(mockGetCaseId).nthCalledWith(2, { counter: 1, - grouping: { 'dest.ip': '0.0.0.3', 'host.name': 'B' }, + grouping: { 'dest.ip': '0.0.0.1', 'host.name': 'B' }, owner: 'cases', ruleId: 'rule-test-id', spaceId: 'default', }); /** - * Oracle record index: 0 - * Should update the counter + * Oracle record index: 3 + * Not found. Created. */ expect(mockGetCaseId).nthCalledWith(3, { - counter: 2, - grouping: { 'dest.ip': '0.0.0.1', 'host.name': 'A' }, + counter: 1, + grouping: { 'dest.ip': '0.0.0.3', 'host.name': 'B' }, owner: 'cases', ruleId: 'rule-test-id', spaceId: 'default', @@ -409,7 +423,7 @@ describe('CasesConnector', () => { }); it('gets the cases correctly', async () => { - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(casesClientMock.cases.bulkGet).toHaveBeenCalledWith({ ids: ['mock-id-1', 'mock-id-2', 'mock-id-3'], @@ -436,11 +450,12 @@ describe('CasesConnector', () => { ], }); - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(casesClientMock.cases.bulkCreate).toHaveBeenCalledWith({ cases: [ { + id: 'mock-id-3', title: 'Test rule (Auto-created)', description: 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `B` and `dest.ip` equals `0.0.0.3`', @@ -473,15 +488,97 @@ describe('CasesConnector', () => { ], }); - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(casesClientMock.cases.bulkCreate).not.toHaveBeenCalled(); }); + + it('does not reopen closed cases if reopenClosedCases=false', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }], + errors: [], + }); + + await connector.run({ + alerts, + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases: false, + }); + + expect(casesClientMock.cases.bulkUpdate).not.toHaveBeenCalled(); + }); + + it('reopen closed cases if reopenClosedCases=true', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }, cases[1]], + errors: [], + }); + + await connector.run({ + alerts, + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases: true, + }); + + expect(casesClientMock.cases.bulkUpdate).toHaveBeenCalledWith({ + cases: [{ id: cases[0].id, status: 'open', version: cases[0].version }], + }); + }); + + it('create new cases if reopenClosedCases=false and there are closed cases', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }, cases[1]], + errors: [], + }); + + mockBulkUpdateRecord.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); + + await connector.run({ + alerts, + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases: false, + }); + + expect(mockBulkUpdateRecord).toHaveBeenCalledWith([ + { payload: { counter: 2 }, recordId: 'so-oracle-record-0', version: 'so-version-0' }, + ]); + + expect(casesClientMock.cases.bulkCreate).toHaveBeenCalledWith({ + cases: [ + { + id: 'mock-id-4', + title: 'Test rule (Auto-created)', + description: + 'This case is auto-created by [Test rule](https://example.com/rules/rule-test-id). \n\n Grouping: `host.name` equals `A` and `dest.ip` equals `0.0.0.1`', + owner: 'cases', + settings: { + syncAlerts: false, + }, + tags: ['auto-generated', ...rule.tags], + connector: { + fields: null, + id: 'none', + name: 'none', + type: '.none', + }, + }, + ], + }); + }); }); describe('Alerts', () => { it('attach the alerts to the correct cases correctly', async () => { - await connector.run({ alerts, groupingBy, owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy, owner, rule, timeWindow, reopenClosedCases }); expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(3); @@ -543,6 +640,101 @@ describe('CasesConnector', () => { ], }); }); + + it('attaches alerts to reopen cases', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }], + errors: [], + }); + + casesClientMock.cases.bulkUpdate.mockResolvedValue([ + { ...cases[0], status: CaseStatuses.open }, + ]); + + await connector.run({ + alerts, + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases: true, + }); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-1', + attachments: [ + { + alertId: 'alert-id-0', + index: 'alert-index-0', + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + { + alertId: 'alert-id-2', + index: 'alert-index-2', + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); + + it('attaches alerts to new created cases if they were closed', async () => { + casesClientMock.cases.bulkGet.mockResolvedValue({ + cases: [{ ...cases[0], status: CaseStatuses.closed }], + errors: [], + }); + + mockBulkUpdateRecord.mockResolvedValue([{ ...oracleRecords[0], counter: 2 }]); + casesClientMock.cases.bulkCreate.mockResolvedValue({ + cases: [{ ...cases[0], id: 'mock-id-4' }], + }); + + await connector.run({ + alerts, + groupingBy, + owner, + rule, + timeWindow, + reopenClosedCases: false, + }); + + expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); + expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { + caseId: 'mock-id-4', + attachments: [ + { + alertId: 'alert-id-0', + index: 'alert-index-0', + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + { + alertId: 'alert-id-2', + index: 'alert-index-2', + owner: 'securitySolution', + rule: { + id: 'rule-test-id', + name: 'Test rule', + }, + type: 'alert', + }, + ], + }); + }); }); }); }); @@ -595,7 +787,7 @@ describe('CasesConnector', () => { describe('Oracle records', () => { it('generates the oracle keys correctly with no grouping', async () => { - await connector.run({ alerts, groupingBy: [], owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy: [], owner, rule, timeWindow, reopenClosedCases }); expect(mockGetRecordId).toHaveBeenCalledTimes(1); @@ -608,7 +800,7 @@ describe('CasesConnector', () => { }); it('gets the oracle records correctly', async () => { - await connector.run({ alerts, groupingBy: [], owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy: [], owner, rule, timeWindow, reopenClosedCases }); expect(mockBulkGetRecords).toHaveBeenCalledWith(['so-oracle-record-0']); }); @@ -616,7 +808,7 @@ describe('CasesConnector', () => { describe('Cases', () => { it('generates the case ids correctly', async () => { - await connector.run({ alerts, groupingBy: [], owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy: [], owner, rule, timeWindow, reopenClosedCases }); expect(mockGetCaseId).toHaveBeenCalledTimes(1); @@ -630,7 +822,7 @@ describe('CasesConnector', () => { }); it('gets the cases correctly', async () => { - await connector.run({ alerts, groupingBy: [], owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy: [], owner, rule, timeWindow, reopenClosedCases }); expect(casesClientMock.cases.bulkGet).toHaveBeenCalledWith({ ids: ['mock-id-1'], @@ -640,7 +832,7 @@ describe('CasesConnector', () => { describe('Alerts', () => { it('attach all alerts to the same case when the grouping is not defined', async () => { - await connector.run({ alerts, groupingBy: [], owner, rule, timeWindow }); + await connector.run({ alerts, groupingBy: [], owner, rule, timeWindow, reopenClosedCases }); expect(casesClientMock.attachments.bulkCreate).toHaveBeenCalledTimes(1); expect(casesClientMock.attachments.bulkCreate).nthCalledWith(1, { diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_connector.ts b/x-pack/plugins/cases/server/connectors/cases/cases_connector.ts index 54ee2f8b108c1..9c86e0fd7b746 100644 --- a/x-pack/plugins/cases/server/connectors/cases/cases_connector.ts +++ b/x-pack/plugins/cases/server/connectors/cases/cases_connector.ts @@ -9,10 +9,11 @@ import stringify from 'json-stable-stringify'; import pMap from 'p-map'; import type { ServiceParams } from '@kbn/actions-plugin/server'; import { SubActionConnector } from '@kbn/actions-plugin/server'; -import { partition, pick } from 'lodash'; +import { pick } from 'lodash'; import type { KibanaRequest } from '@kbn/core-http-server'; import { CoreKibanaRequest } from '@kbn/core/server'; import dateMath from '@kbn/datemath'; +import { CaseStatuses } from '@kbn/cases-components'; import type { BulkCreateCasesRequest } from '../../../common/types/api'; import type { Case } from '../../../common'; import { ConnectorTypes, AttachmentType } from '../../../common'; @@ -23,7 +24,6 @@ import type { CasesConnectorRunParams, CasesConnectorSecrets, OracleRecord, - OracleRecordCreateRequest, } from './types'; import { CasesConnectorRunParamsSchema } from './schema'; import { CasesOracleService } from './cases_oracle_service'; @@ -43,7 +43,8 @@ interface GroupedAlerts { } type GroupedAlertsWithOracleKey = GroupedAlerts & { oracleKey: string }; -type GroupedAlertsWithCaseId = GroupedAlertsWithOracleKey & { caseId: string }; +type GroupedAlertsWithOracleRecords = GroupedAlertsWithOracleKey & { oracleRecord: OracleRecord }; +type GroupedAlertsWithCaseId = GroupedAlertsWithOracleRecords & { caseId: string }; type GroupedAlertsWithCases = GroupedAlertsWithCaseId & { theCase: Case }; export class CasesConnector extends SubActionConnector< @@ -103,29 +104,77 @@ export class CasesConnector extends SubActionConnector< const casesClient = await this.casesParams.getCasesClient(this.kibanaRequest); const groupedAlerts = this.groupAlerts({ alerts, groupingBy }); + + /** + * Based on the rule ID, the grouping, the owner, the space ID, + * the oracle record ID is generated + */ const groupedAlertsWithOracleKey = this.generateOracleKeys(params, groupedAlerts); /** - * Add circuit breakers to the number of oracles they can be created or retrieved + * TODO: Add circuit breakers to the number of oracles they can be created or retrieved + */ + + /** + * Gets all records by the IDs that produces in generateOracleKeys. + * If a record does not exist it will create the record. + * A record does not exist if it is the first time the connector run for a specific grouping. + * The returned map will contain all records old and new. + */ + const oracleRecordsMap = await this.upsertOracleRecords(groupedAlertsWithOracleKey); + + /** + * If the time window has passed for a case we need to create a new case. + * To do that we need to increase the record counter by one. Increasing the + * counter will generate a new case ID for the same grouping. + * The returned map contain all records with their counters updated correctly */ - const oracleRecords = await this.upsertOracleRecords( + + const oracleRecordMapWithTimeWindowHandled = await this.handleTimeWindow( params, - Array.from(groupedAlertsWithOracleKey.values()) + oracleRecordsMap ); + /** + * Based on the rule ID, the grouping, the owner, the space ID, + * and the counter of the oracle record the case ID is generated + */ const groupedAlertsWithCaseId = this.generateCaseIds( params, - groupedAlertsWithOracleKey, - oracleRecords + oracleRecordMapWithTimeWindowHandled ); + /** + * Gets all records by the IDs that produces in generateCaseIds. + * If a case does not exist it will create the case. + * A case does not exist if it is the first time the connector run for a specific grouping + * or the time window has elapsed and a new one should be created for the same grouping. + * The returned map will contain all cases old and new. + */ const groupedAlertsWithCases = await this.upsertCases( params, casesClient, groupedAlertsWithCaseId ); - await this.attachAlertsToCases(casesClient, groupedAlertsWithCases, params); + /** + * A user can configure how to handle closed cases. Based on the configuration + * we open the closed cases by updating their status or we create new cases by + * increasing the counter of the corresponding oracle record, generating the new + * case ID, and creating the new case. + * The map contains all cases updated and new without any remaining closed case. + */ + const groupedAlertsWithClosedCasesHandled = await this.handleClosedCases( + params, + casesClient, + groupedAlertsWithCases + ); + + /** + * Now that all cases are fetched or created per grouping, we attach the alerts + * to the corresponding cases. + */ + await this.attachAlertsToCases(casesClient, groupedAlertsWithClosedCasesHandled, params); } private groupAlerts({ @@ -185,49 +234,92 @@ export class CasesConnector extends SubActionConnector< } private async upsertOracleRecords( - params: CasesConnectorRunParams, - groupedAlertsWithOracleKey: GroupedAlertsWithOracleKey[] - ): Promise { - const { timeWindow } = params; + groupedAlertsWithOracleKey: Map + ): Promise> { const bulkCreateReq: BulkCreateOracleRecordRequest = []; + const oracleRecordMap = new Map(); + + const addRecordToMap = (oracleRecords: OracleRecord[]) => { + for (const record of oracleRecords) { + if (groupedAlertsWithOracleKey.has(record.id)) { + const data = groupedAlertsWithOracleKey.get(record.id) as GroupedAlertsWithCaseId; + oracleRecordMap.set(record.id, { ...data, oracleRecord: record }); + } + } + }; - const ids = groupedAlertsWithOracleKey.map(({ oracleKey }) => oracleKey); + const ids = Array.from(groupedAlertsWithOracleKey.values()).map(({ oracleKey }) => oracleKey); const bulkGetRes = await this.casesOracleService.bulkGetRecords(ids); const [bulkGetValidRecords, bulkGetRecordsErrors] = partitionRecordsByError(bulkGetRes); - const [recordsToIncreaseCounter, recordsWithoutIncreasedCounter] = partition( - bulkGetValidRecords, - (req) => this.isTimeWindowPassed(timeWindow, req.updatedAt ?? req.createdAt) - ); + addRecordToMap(bulkGetValidRecords); - if (bulkGetRecordsErrors.length === 0 && recordsToIncreaseCounter.length === 0) { - return bulkGetValidRecords; + if (bulkGetRecordsErrors.length === 0) { + return oracleRecordMap; } - const recordsMap = new Map( - groupedAlertsWithOracleKey.map(({ oracleKey, grouping }) => [ - oracleKey, - // TODO: Add the rule info - { cases: [], rules: [], grouping }, - ]) - ); - /** * TODO: Throw/retry for other errors */ const nonFoundErrors = bulkGetRecordsErrors.filter((error) => error.statusCode === 404); for (const error of nonFoundErrors) { - if (error.id && recordsMap.has(error.id)) { + if (error.id && groupedAlertsWithOracleKey.has(error.id)) { + const record = groupedAlertsWithOracleKey.get(error.id); bulkCreateReq.push({ recordId: error.id, - payload: recordsMap.get(error.id) ?? { cases: [], rules: [], grouping: {} }, + // TODO: Add the rule info + payload: { cases: [], rules: [], grouping: record?.grouping ?? {} }, }); } } - const bulkUpdateReq = recordsToIncreaseCounter.map((record) => ({ + const bulkCreateRes = await this.casesOracleService.bulkCreateRecord(bulkCreateReq); + + /** + * TODO: Throw/Retry on errors + */ + const [bulkCreateValidRecords, _bulkCreateErrors] = partitionRecordsByError(bulkCreateRes); + + addRecordToMap(bulkCreateValidRecords); + + return oracleRecordMap; + } + + private async handleTimeWindow( + params: CasesConnectorRunParams, + oracleRecordMap: Map + ) { + const { timeWindow } = params; + const oracleRecordMapWithIncreasedCounters = new Map(oracleRecordMap); + + const recordsToIncreaseCounter = Array.from(oracleRecordMap.values()) + .filter(({ oracleRecord }) => + this.isTimeWindowPassed(timeWindow, oracleRecord.updatedAt ?? oracleRecord.createdAt) + ) + .map(({ oracleRecord }) => oracleRecord); + + const bulkUpdateValidRecords = await this.increaseOracleRecordCounter(recordsToIncreaseCounter); + + for (const res of bulkUpdateValidRecords) { + if (oracleRecordMap.has(res.id)) { + const data = oracleRecordMap.get(res.id) as GroupedAlertsWithOracleRecords; + oracleRecordMapWithIncreasedCounters.set(res.id, { ...data, oracleRecord: res }); + } + } + + return oracleRecordMapWithIncreasedCounters; + } + + private async increaseOracleRecordCounter( + oracleRecords: OracleRecord[] + ): Promise { + if (oracleRecords.length === 0) { + return []; + } + + const bulkUpdateReq = oracleRecords.map((record) => ({ recordId: record.id, version: record.version, /** @@ -236,24 +328,13 @@ export class CasesConnector extends SubActionConnector< payload: { counter: record.counter + 1 }, })); - const bulkCreateRes = await this.casesOracleService.bulkCreateRecord(bulkCreateReq); const bulkUpdateRes = await this.casesOracleService.bulkUpdateRecord(bulkUpdateReq); - /** * TODO: Throw/Retry on errors */ - const [bulkCreateValidRecords, _bulkCreateErrors] = partitionRecordsByError(bulkCreateRes); const [bulkUpdateValidRecords, _bulkUpdateErrors] = partitionRecordsByError(bulkUpdateRes); - /** - * TODO: Should we check if the records in the - * arrays are unique? - */ - return [ - ...recordsWithoutIncreasedCounter, - ...bulkCreateValidRecords, - ...bulkUpdateValidRecords, - ]; + return bulkUpdateValidRecords; } private isTimeWindowPassed(timeWindow: string, counterLastUpdatedAt: string) { @@ -280,8 +361,7 @@ export class CasesConnector extends SubActionConnector< private generateCaseIds( params: CasesConnectorRunParams, - groupedAlertsWithOracleKey: Map, - oracleRecords: OracleRecord[] + groupedAlertsWithOracleRecords: Map ): Map { const { rule, owner } = params; @@ -292,21 +372,22 @@ export class CasesConnector extends SubActionConnector< const casesMap = new Map(); - for (const oracleRecord of oracleRecords) { - const { alerts, grouping } = groupedAlertsWithOracleKey.get(oracleRecord.id) ?? { - alerts: [], - grouping: {}, - }; - + for (const [recordId, entry] of groupedAlertsWithOracleRecords.entries()) { const caseId = this.casesService.getCaseId({ ruleId: rule.id, - grouping, + grouping: entry.grouping, owner, spaceId, - counter: oracleRecord.counter, + counter: entry.oracleRecord.counter, }); - casesMap.set(caseId, { caseId, alerts, grouping, oracleKey: oracleRecord.id }); + casesMap.set(caseId, { + caseId, + alerts: entry.alerts, + grouping: entry.grouping, + oracleKey: recordId, + oracleRecord: entry.oracleRecord, + }); } return casesMap; @@ -356,10 +437,10 @@ export class CasesConnector extends SubActionConnector< */ const bulkCreateCasesResponse = await casesClient.cases.bulkCreate({ cases: bulkCreateReq }); - for (const res of bulkCreateCasesResponse.cases) { - if (groupedAlertsWithCaseId.has(res.id)) { - const data = groupedAlertsWithCaseId.get(res.id) as GroupedAlertsWithCaseId; - casesMap.set(res.id, { ...data, theCase: res }); + for (const theCase of bulkCreateCasesResponse.cases) { + if (groupedAlertsWithCaseId.has(theCase.id)) { + const data = groupedAlertsWithCaseId.get(theCase.id) as GroupedAlertsWithCaseId; + casesMap.set(theCase.id, { ...data, theCase }); } } @@ -369,8 +450,8 @@ export class CasesConnector extends SubActionConnector< private getCreateCaseRequest( params: CasesConnectorRunParams, groupingData: GroupedAlertsWithCaseId - ) { - const { grouping } = groupingData; + ): BulkCreateCasesRequest['cases'][number] { + const { grouping, caseId } = groupingData; const ruleName = params.rule.ruleUrl ? `[${params.rule.name}](${params.rule.ruleUrl})` @@ -389,6 +470,7 @@ export class CasesConnector extends SubActionConnector< * We should find a way to fill the custom fields with default values. */ return { + id: caseId, description, tags: ['auto-generated', ...tags], /** @@ -418,6 +500,115 @@ export class CasesConnector extends SubActionConnector< .join(' and '); } + private async handleClosedCases( + params: CasesConnectorRunParams, + casesClient: CasesClient, + casesMap: Map + ) { + const entriesWithClosedCases = Array.from(casesMap.values()).filter( + (theCase) => theCase.theCase.status === CaseStatuses.closed + ); + + if (entriesWithClosedCases.length === 0) { + return casesMap; + } + + const res = params.reopenClosedCases + ? await this.reopenClosedCases(casesClient, entriesWithClosedCases, casesMap) + : await this.createNewCasesOutOfClosedCases( + params, + casesClient, + entriesWithClosedCases, + casesMap + ); + + /** + * The initial map contained the closed cases. We need to remove them to + * avoid attaching alerts to a close case + */ + return new Map([...res].filter(([_, record]) => record.theCase.status !== CaseStatuses.closed)); + } + + private async reopenClosedCases( + casesClient: CasesClient, + closedCasesEntries: GroupedAlertsWithCases[], + casesMap: Map + ): Promise> { + const casesMapWithClosedCasesOpened = new Map(casesMap); + + const bulkUpdateReq = closedCasesEntries.map((entry) => ({ + id: entry.theCase.id, + version: entry.theCase.version, + status: CaseStatuses.open, + })); + + /** + * TODO: bulkUpdate throws an error. Retry on errors. + */ + const bulkUpdateCasesResponse = await casesClient.cases.bulkUpdate({ cases: bulkUpdateReq }); + + for (const res of bulkUpdateCasesResponse) { + if (casesMap.has(res.id)) { + const data = casesMap.get(res.id) as GroupedAlertsWithCases; + casesMapWithClosedCasesOpened.set(res.id, { ...data, theCase: res }); + } + } + + return casesMapWithClosedCasesOpened; + } + + private async createNewCasesOutOfClosedCases( + params: CasesConnectorRunParams, + casesClient: CasesClient, + closedCasesEntries: GroupedAlertsWithCases[], + casesMap: Map + ): Promise> { + const casesMapWithNewCases = new Map(casesMap); + const casesMapAsArray = Array.from(casesMap.values()); + + const findEntryByOracleRecord = (oracleId: string) => { + return casesMapAsArray.find((record) => record.oracleRecord.id === oracleId); + }; + + const bulkUpdateOracleValidRecords = await this.increaseOracleRecordCounter( + closedCasesEntries.map((entry) => entry.oracleRecord) + ); + + const groupedAlertsWithOracleRecords = new Map(); + + for (const record of bulkUpdateOracleValidRecords) { + const foundRecord = findEntryByOracleRecord(record.id); + + if (foundRecord) { + groupedAlertsWithOracleRecords.set(record.id, { + oracleKey: record.id, + oracleRecord: foundRecord.oracleRecord, + alerts: foundRecord.alerts, + grouping: foundRecord.grouping, + }); + } + } + + const groupedAlertsWithCaseId = this.generateCaseIds(params, groupedAlertsWithOracleRecords); + const bulkCreateReq = Array.from(groupedAlertsWithCaseId.values()).map((record) => + this.getCreateCaseRequest(params, record) + ); + + /** + * TODO: bulkCreate throws an error. Retry on errors. + */ + const bulkCreateCasesResponse = await casesClient.cases.bulkCreate({ cases: bulkCreateReq }); + + for (const theCase of bulkCreateCasesResponse.cases) { + if (groupedAlertsWithCaseId.has(theCase.id)) { + const data = groupedAlertsWithCaseId.get(theCase.id) as GroupedAlertsWithCaseId; + casesMapWithNewCases.set(theCase.id, { ...data, theCase }); + } + } + + return casesMapWithNewCases; + } + private async attachAlertsToCases( casesClient: CasesClient, groupedAlertsWithCases: Map, diff --git a/x-pack/plugins/cases/server/connectors/cases/schema.test.ts b/x-pack/plugins/cases/server/connectors/cases/schema.test.ts index b28f7d9feb3bc..7016a5b2f0853 100644 --- a/x-pack/plugins/cases/server/connectors/cases/schema.test.ts +++ b/x-pack/plugins/cases/server/connectors/cases/schema.test.ts @@ -17,7 +17,28 @@ describe('CasesConnectorRunParamsSchema', () => { }); it('accepts valid params', () => { - expect(() => CasesConnectorRunParamsSchema.validate(getParams())).not.toThrow(); + expect(CasesConnectorRunParamsSchema.validate(getParams())).toMatchInlineSnapshot(` + Object { + "alerts": Array [ + Object { + "_id": "alert-id", + "_index": "alert-index", + }, + ], + "groupingBy": Array [ + "host.name", + ], + "owner": "cases", + "reopenClosedCases": false, + "rule": Object { + "id": "rule-id", + "name": "Test rule", + "ruleUrl": "https://example.com", + "tags": Array [], + }, + "timeWindow": "7d", + } + `); }); describe('alerts', () => { @@ -152,4 +173,10 @@ describe('CasesConnectorRunParamsSchema', () => { expect(CasesConnectorRunParamsSchema.validate(getParams()).timeWindow).toBe('7d'); }); }); + + describe('reopenClosedCases', () => { + it('defaults the reopenClosedCases to false', () => { + expect(CasesConnectorRunParamsSchema.validate(getParams()).reopenClosedCases).toBe(false); + }); + }); }); diff --git a/x-pack/plugins/cases/server/connectors/cases/schema.ts b/x-pack/plugins/cases/server/connectors/cases/schema.ts index 6cf9fb43cb700..2f345b3202bb9 100644 --- a/x-pack/plugins/cases/server/connectors/cases/schema.ts +++ b/x-pack/plugins/cases/server/connectors/cases/schema.ts @@ -71,4 +71,5 @@ export const CasesConnectorRunParamsSchema = schema.object({ } }, }), + reopenClosedCases: schema.boolean({ defaultValue: false }), }); diff --git a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts index 16f3c8e6baa60..f9751c2ae8f12 100644 --- a/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/cases/server/routes/api/cases/patch_cases.ts @@ -20,7 +20,7 @@ export const patchCaseRoute = createCasesRoute({ const casesClient = await caseContext.getCasesClient(); const cases = request.body as caseApiV1.CasesPatchRequest; - const res: caseDomainV1.Cases = await casesClient.cases.update(cases); + const res: caseDomainV1.Cases = await casesClient.cases.bulkUpdate(cases); return response.ok({ body: res,