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 1b064934d101bd0..8714d97e88bd56a 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 @@ -12,10 +12,13 @@ import { CasesConnector } from './cases_connector'; import { CASES_CONNECTOR_ID } from './constants'; import { CASE_ORACLE_SAVED_OBJECT } from '../../../common/constants'; import { CasesOracleService } from './cases_oracle_service'; +import { CasesService } from './cases_service'; jest.mock('./cases_oracle_service'); +jest.mock('./cases_service'); const CasesOracleServiceMock = CasesOracleService as jest.Mock; +const CasesServiceMock = CasesService as jest.Mock; describe('CasesConnector', () => { const services = actionsMock.createServices(); @@ -81,8 +84,9 @@ describe('CasesConnector', () => { ]; const mockGetRecordId = jest.fn(); - const bulkGetRecords = jest.fn(); - const bulkCreateRecord = jest.fn(); + const mockBulkGetRecords = jest.fn(); + const mockBulkCreateRecord = jest.fn(); + const mockGetCaseId = jest.fn(); let connector: CasesConnector; @@ -90,17 +94,29 @@ describe('CasesConnector', () => { jest.clearAllMocks(); CasesOracleServiceMock.mockImplementation(() => { - let idCounter = 0; + let oracleIdCounter = 0; return { - getRecordId: mockGetRecordId.mockImplementation(() => `so-oracle-record-${idCounter++}`), - bulkGetRecords: bulkGetRecords.mockResolvedValue(oracleRecords), - bulkCreateRecord: bulkCreateRecord.mockResolvedValue({ - ...oracleRecords[0], - id: groupedAlertsWithOracleKey[2].oracleKey, - grouping: groupedAlertsWithOracleKey[2].grouping, - version: 'so-version-2', - }), + getRecordId: mockGetRecordId.mockImplementation( + () => `so-oracle-record-${oracleIdCounter++}` + ), + bulkGetRecords: mockBulkGetRecords.mockResolvedValue(oracleRecords), + bulkCreateRecord: mockBulkCreateRecord.mockResolvedValue([ + { + ...oracleRecords[0], + id: groupedAlertsWithOracleKey[2].oracleKey, + grouping: groupedAlertsWithOracleKey[2].grouping, + version: 'so-version-2', + }, + ]), + }; + }); + + CasesServiceMock.mockImplementation(() => { + let caseIdCounter = 0; + + return { + getCaseId: mockGetCaseId.mockImplementation(() => `so-case-id-${caseIdCounter++}`), }; }); @@ -119,6 +135,8 @@ describe('CasesConnector', () => { it('generates the oracle keys correctly', async () => { await connector.run({ alerts, groupingBy, owner, rule }); + expect(mockGetRecordId).toHaveBeenCalledTimes(3); + for (const [index, { grouping }] of groupedAlertsWithOracleKey.entries()) { expect(mockGetRecordId).nthCalledWith(index + 1, { ruleId: rule.id, @@ -132,7 +150,7 @@ describe('CasesConnector', () => { it('gets the oracle records correctly', async () => { await connector.run({ alerts, groupingBy, owner, rule }); - expect(bulkGetRecords).toHaveBeenCalledWith([ + expect(mockBulkGetRecords).toHaveBeenCalledWith([ groupedAlertsWithOracleKey[0].oracleKey, groupedAlertsWithOracleKey[1].oracleKey, groupedAlertsWithOracleKey[2].oracleKey, @@ -142,7 +160,7 @@ describe('CasesConnector', () => { it('created the no found oracle records correctly', async () => { await connector.run({ alerts, groupingBy, owner, rule }); - expect(bulkCreateRecord).toHaveBeenCalledWith([ + expect(mockBulkCreateRecord).toHaveBeenCalledWith([ { recordId: groupedAlertsWithOracleKey[2].oracleKey, payload: { @@ -155,11 +173,29 @@ describe('CasesConnector', () => { }); it('does not create oracle records if there are no 404 errors', async () => { - bulkGetRecords.mockResolvedValue([oracleRecords[0]]); + mockBulkGetRecords.mockResolvedValue([oracleRecords[0]]); await connector.run({ alerts, groupingBy, owner, rule }); - expect(bulkCreateRecord).not.toHaveBeenCalled(); + expect(mockBulkCreateRecord).not.toHaveBeenCalled(); + }); + }); + + describe('Cases', () => { + it('generates the case ids correctly', async () => { + await connector.run({ alerts, groupingBy, owner, rule }); + + expect(mockGetCaseId).toHaveBeenCalledTimes(3); + + for (const [index, { grouping }] of groupedAlertsWithOracleKey.entries()) { + expect(mockGetCaseId).nthCalledWith(index + 1, { + ruleId: rule.id, + grouping, + owner, + spaceId: 'default', + counter: 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 0ed9ba47e054664..f5768102579e011 100644 --- a/x-pack/plugins/cases/server/connectors/cases/cases_connector.ts +++ b/x-pack/plugins/cases/server/connectors/cases/cases_connector.ts @@ -21,6 +21,7 @@ import type { import { CasesConnectorRunParamsSchema } from './schema'; import { CasesOracleService } from './cases_oracle_service'; import { partitionRecords } from './utils'; +import { CasesService } from './cases_service'; interface GroupedAlerts { alerts: CasesConnectorRunParams['alerts']; @@ -28,15 +29,18 @@ interface GroupedAlerts { } type GroupedAlertsWithOracleKey = GroupedAlerts & { oracleKey: string }; +type GroupedAlertsWithCaseId = GroupedAlertsWithOracleKey & { caseId: string }; export class CasesConnector extends SubActionConnector< CasesConnectorConfig, CasesConnectorSecrets > { - private readonly casesOracleService; + private readonly casesOracleService: CasesOracleService; + private readonly casesService: CasesService; constructor(params: ServiceParams) { super(params); + this.casesOracleService = new CasesOracleService({ log: this.logger, /** @@ -46,6 +50,9 @@ export class CasesConnector extends SubActionConnector< */ unsecuredSavedObjectsClient: this.savedObjectsClient, }); + + this.casesService = new CasesService(); + this.registerSubActions(); } @@ -66,6 +73,30 @@ export class CasesConnector extends SubActionConnector< throw new Error('Method not implemented.'); } + public async run(params: CasesConnectorRunParams) { + const { alerts, groupingBy } = params; + + /** + * TODO: Handle when grouping is not defined + * One case should be created per rule + */ + const groupedAlerts = this.groupAlerts({ alerts, groupingBy }); + const groupedAlertsWithOracleKey = this.generateOracleKeys(params, groupedAlerts); + + /** + * Add circuit breakers to the number of oracles they can be created or retrieved + */ + const oracleRecords = await this.bulkGetOrCreateOracleRecords( + Array.from(groupedAlertsWithOracleKey.values()) + ); + + const groupedAlertsWithCaseId = this.generateCaseIds( + params, + groupedAlertsWithOracleKey, + oracleRecords + ); + } + private groupAlerts({ alerts, groupingBy, @@ -94,7 +125,7 @@ export class CasesConnector extends SubActionConnector< private generateOracleKeys( params: CasesConnectorRunParams, groupedAlerts: GroupedAlerts[] - ): GroupedAlertsWithOracleKey[] { + ): Map { const { rule, owner } = params; /** * TODO: Take spaceId from the actions framework @@ -114,7 +145,7 @@ export class CasesConnector extends SubActionConnector< oracleMap.set(oracleKey, { oracleKey, grouping, alerts }); } - return Array.from(oracleMap.values()); + return oracleMap; } private async bulkGetOrCreateOracleRecords( @@ -162,22 +193,37 @@ export class CasesConnector extends SubActionConnector< return [...bulkGetValidRecords, ...bulkCreateValidRecords]; } - public async run(params: CasesConnectorRunParams) { - const { alerts, groupingBy } = params; + private generateCaseIds( + params: CasesConnectorRunParams, + groupedAlertsWithOracleKey: Map, + oracleRecords: OracleRecord[] + ): Map { + const { rule, owner } = params; /** - * TODO: Handle when grouping is not defined - * One case should be created per rule - */ - const groupedAlerts = this.groupAlerts({ alerts, groupingBy }); - const groupedAlertsWithOracleKey = this.generateOracleKeys(params, groupedAlerts); - console.log( - '🚀 ~ file: cases_connector.ts:175 ~ run ~ groupedAlertsWithOracleKey:', - groupedAlertsWithOracleKey - ); - /** - * Add circuit breakers to the number of oracles they can be created or retrieved + * TODO: Take spaceId from the actions framework */ - const oracleRecords = this.bulkGetOrCreateOracleRecords(groupedAlertsWithOracleKey); + const spaceId = 'default'; + + const casesMap = new Map(); + + for (const oracleRecord of oracleRecords) { + const { alerts, grouping } = groupedAlertsWithOracleKey.get(oracleRecord.id) ?? { + alerts: [], + grouping: {}, + }; + + const caseId = this.casesService.getCaseId({ + ruleId: rule.id, + grouping, + owner, + spaceId, + counter: oracleRecord.counter, + }); + + casesMap.set(caseId, { caseId, alerts, grouping, oracleKey: oracleRecord.id }); + } + + return casesMap; } } diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_service.test.ts b/x-pack/plugins/cases/server/connectors/cases/cases_service.test.ts new file mode 100644 index 000000000000000..5ea9b51bad3ab7c --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_service.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 { createHash } from 'node:crypto'; +import stringify from 'json-stable-stringify'; + +import { isEmpty, set } from 'lodash'; +import { CasesService } from './cases_service'; + +describe('CasesService', () => { + let service: CasesService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new CasesService(); + }); + + describe('getCaseId', () => { + it('return the record ID correctly', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const grouping = { 'host.ip': '0.0.0.1' }; + const counter = 1; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex); + }); + + it('sorts the grouping definition correctly', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const grouping = { 'host.ip': '0.0.0.1', 'agent.id': '8a4f500d' }; + const sortedGrouping = { 'agent.id': '8a4f500d', 'host.ip': '0.0.0.1' }; + const counter = 1; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(sortedGrouping)}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex); + }); + + it('return the record ID correctly without grouping', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const counter = 1; + + const payload = `${ruleId}:${spaceId}:${owner}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ruleId, spaceId, owner, counter })).toEqual(hex); + }); + + it('return the record ID correctly with empty grouping', async () => { + const ruleId = 'test-rule-id'; + const spaceId = 'default'; + const owner = 'cases'; + const grouping = {}; + const counter = 1; + + const payload = `${ruleId}:${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ruleId, spaceId, owner, grouping, counter })).toEqual(hex); + }); + + it('return the record ID correctly without rule', async () => { + const spaceId = 'default'; + const owner = 'cases'; + const grouping = { 'host.ip': '0.0.0.1' }; + const counter = 1; + + const payload = `${spaceId}:${owner}:${stringify(grouping)}:${counter}`; + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ spaceId, owner, grouping, counter })).toEqual(hex); + }); + + it('throws an error when the ruleId and the grouping is missing', async () => { + const spaceId = 'default'; + const owner = 'cases'; + const counter = 1; + + expect(() => + // @ts-expect-error: ruleId and grouping are omitted for testing + service.getCaseId({ spaceId, owner, counter }) + ).toThrowErrorMatchingInlineSnapshot(`"ruleID or grouping is required"`); + }); + + it.each(['ruleId', 'spaceId', 'owner'])( + 'return the record ID correctly with empty string for %s', + async (key) => { + const getPayloadValue = (value: string) => (isEmpty(value) ? '' : `${value}:`); + + const params = { + ruleId: 'test-rule-id', + spaceId: 'default', + owner: 'cases', + }; + + const grouping = { 'host.ip': '0.0.0.1' }; + const counter = 1; + + set(params, key, ''); + + const payload = `${getPayloadValue(params.ruleId)}${getPayloadValue( + params.spaceId + )}${getPayloadValue(params.owner)}${stringify(grouping)}:${counter}`; + + const hash = createHash('sha256'); + + hash.update(payload); + + const hex = hash.digest('hex'); + + expect(service.getCaseId({ ...params, grouping, counter })).toEqual(hex); + } + ); + }); +}); diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_service.ts b/x-pack/plugins/cases/server/connectors/cases/cases_service.ts new file mode 100644 index 000000000000000..214049e5acc0ccf --- /dev/null +++ b/x-pack/plugins/cases/server/connectors/cases/cases_service.ts @@ -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 { CryptoService } from './crypto_service'; +import type { CaseIdPayload } from './types'; + +export class CasesService { + private cryptoService: CryptoService; + + constructor() { + this.cryptoService = new CryptoService(); + } + + public getCaseId({ ruleId, spaceId, owner, grouping, counter }: CaseIdPayload): string { + if (grouping == null && ruleId == null) { + throw new Error('ruleID or grouping is required'); + } + + const payload = [ + ruleId, + spaceId, + owner, + this.cryptoService.stringifyDeterministically(grouping), + counter, + ] + .filter(Boolean) + .join(':'); + + return this.cryptoService.getHash(payload); + } +} diff --git a/x-pack/plugins/cases/server/connectors/cases/types.ts b/x-pack/plugins/cases/server/connectors/cases/types.ts index d784a7b63068345..ac55e90b0b61f3a 100644 --- a/x-pack/plugins/cases/server/connectors/cases/types.ts +++ b/x-pack/plugins/cases/server/connectors/cases/types.ts @@ -32,6 +32,8 @@ type OracleKeyWithOptionalGrouping = Optional; export type OracleKey = ExclusiveUnion; +export type CaseIdPayload = OracleKey & { counter: number }; + export interface OracleRecord { id: string; counter: number;