diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts index 599c32137c553..186962b568792 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts @@ -14,6 +14,7 @@ import { schema as s, ObjectType } from '@kbn/config-schema'; * Currently supported: * - filter * - histogram + * - nested * - terms * * Not implemented: @@ -32,7 +33,6 @@ import { schema as s, ObjectType } from '@kbn/config-schema'; * - ip_range * - missing * - multi_terms - * - nested * - parent * - range * - rare_terms @@ -42,6 +42,7 @@ import { schema as s, ObjectType } from '@kbn/config-schema'; * - significant_text * - variable_width_histogram */ + export const bucketAggsSchemas: Record = { filter: s.object({ term: s.recordOf(s.string(), s.oneOf([s.string(), s.boolean(), s.number()])), @@ -71,6 +72,9 @@ export const bucketAggsSchemas: Record = { }) ), }), + nested: s.object({ + path: s.string(), + }), terms: s.object({ field: s.maybe(s.string()), collect_mode: s.maybe(s.string()), diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts index 8a7c1c3719eb0..57421db76f5b6 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts @@ -16,6 +16,12 @@ const mockMappings = { updated_at: { type: 'date', }, + references: { + type: 'nested', + properties: { + id: 'keyword', + }, + }, foo: { properties: { title: { @@ -182,6 +188,40 @@ describe('validateAndConvertAggregations', () => { }); }); + it('validates a nested root aggregations', () => { + expect( + validateAndConvertAggregations( + ['alert'], + { + aggName: { + nested: { + path: 'alert.references', + }, + aggregations: { + aggName2: { + terms: { field: 'alert.references.id' }, + }, + }, + }, + }, + mockMappings + ) + ).toEqual({ + aggName: { + nested: { + path: 'references', + }, + aggregations: { + aggName2: { + terms: { + field: 'references.id', + }, + }, + }, + }, + }); + }); + it('rewrites type attributes when valid', () => { const aggregations: AggsMap = { average: { @@ -428,4 +468,50 @@ describe('validateAndConvertAggregations', () => { `"[someAgg.aggs.nested.max.script]: definition for this key is missing"` ); }); + + it('throws an error when trying to access a property via {type}.{type}.attributes.{attr}', () => { + expect(() => { + validateAndConvertAggregations( + ['alert'], + { + aggName: { + cardinality: { + field: 'alert.alert.attributes.actions.group', + }, + aggs: { + aggName: { + max: { field: 'alert.alert.attributes.actions.group' }, + }, + }, + }, + }, + mockMappings + ); + }).toThrowErrorMatchingInlineSnapshot( + '"[aggName.cardinality.field] Invalid attribute path: alert.alert.attributes.actions.group"' + ); + }); + + it('throws an error when trying to access a property via {type}.{type}.{attr}', () => { + expect(() => { + validateAndConvertAggregations( + ['alert'], + { + aggName: { + cardinality: { + field: 'alert.alert.actions.group', + }, + aggs: { + aggName: { + max: { field: 'alert.alert.actions.group' }, + }, + }, + }, + }, + mockMappings + ); + }).toThrowErrorMatchingInlineSnapshot( + '"[aggName.cardinality.field] Invalid attribute path: alert.alert.actions.group"' + ); + }); }); diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.ts index a2fd392183132..cd41a23f4a28b 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/validation.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/validation.ts @@ -56,10 +56,13 @@ const validateAggregations = ( aggregations: Record, context: ValidationContext ) => { - return Object.entries(aggregations).reduce((memo, [aggrName, aggrContainer]) => { - memo[aggrName] = validateAggregation(aggrContainer, childContext(context, aggrName)); - return memo; - }, {} as Record); + return Object.entries(aggregations).reduce>( + (memo, [aggrName, aggrContainer]) => { + memo[aggrName] = validateAggregation(aggrContainer, childContext(context, aggrName)); + return memo; + }, + {} + ); }; /** @@ -93,15 +96,18 @@ const validateAggregationContainer = ( container: estypes.AggregationContainer, context: ValidationContext ) => { - return Object.entries(container).reduce((memo, [aggName, aggregation]) => { - if (aggregationKeys.includes(aggName)) { - return memo; - } - return { - ...memo, - [aggName]: validateAggregationType(aggName, aggregation, childContext(context, aggName)), - }; - }, {} as estypes.AggregationContainer); + return Object.entries(container).reduce( + (memo, [aggName, aggregation]) => { + if (aggregationKeys.includes(aggName)) { + return memo; + } + return { + ...memo, + [aggName]: validateAggregationType(aggName, aggregation, childContext(context, aggName)), + }; + }, + {} + ); }; const validateAggregationType = ( @@ -143,7 +149,7 @@ const validateAggregationStructure = ( * }, * ``` */ -const attributeFields = ['field']; +const attributeFields = ['field', 'path']; /** * List of fields that have a Record as value * diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts index f817497e3759e..0b2cc8e235c9c 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/validation_utils.ts @@ -24,15 +24,17 @@ export const isRootLevelAttribute = ( allowedTypes: string[] ): boolean => { const splits = attributePath.split('.'); - if (splits.length !== 2) { + if (splits.length <= 1) { return false; } - const [type, fieldName] = splits; - if (allowedTypes.includes(fieldName)) { + const [type, firstPath, ...otherPaths] = splits; + if (allowedTypes.includes(firstPath)) { return false; } - return allowedTypes.includes(type) && fieldDefined(indexMapping, fieldName); + return ( + allowedTypes.includes(type) && fieldDefined(indexMapping, [firstPath, ...otherPaths].join('.')) + ); }; /** @@ -45,7 +47,8 @@ export const isRootLevelAttribute = ( * ``` */ export const rewriteRootLevelAttribute = (attributePath: string) => { - return attributePath.split('.')[1]; + const [, ...attributes] = attributePath.split('.'); + return attributes.join('.'); }; /** diff --git a/x-pack/plugins/cases/common/api/cases/comment.ts b/x-pack/plugins/cases/common/api/cases/comment.ts index 41ad0e87f14d2..8434e419ef75a 100644 --- a/x-pack/plugins/cases/common/api/cases/comment.ts +++ b/x-pack/plugins/cases/common/api/cases/comment.ts @@ -9,6 +9,21 @@ import * as rt from 'io-ts'; import { UserRT } from '../user'; +const BucketsAggs = rt.array( + rt.type({ + key: rt.string, + }) +); + +export const GetCaseIdsByAlertIdAggsRt = rt.type({ + references: rt.type({ + doc_count: rt.number, + caseIds: rt.type({ + buckets: BucketsAggs, + }), + }), +}); + /** * this is used to differentiate between an alert attached to a top-level case and a group of alerts that should only * be attached to a sub case. The reason we need this is because an alert group comment will have references to both a case and @@ -123,3 +138,4 @@ export type CommentPatchRequest = rt.TypeOf; export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; export type CommentRequestAlertType = rt.TypeOf; +export type GetCaseIdsByAlertIdAggs = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index f9fae2466a59b..966305524c059 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -31,6 +31,8 @@ export const CASE_STATUS_URL = `${CASES_URL}/status`; export const CASE_TAGS_URL = `${CASES_URL}/tags`; export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`; +export const CASE_ALERTS_URL = `${CASES_URL}/alerts/{alert_id}`; + /** * Action routes */ diff --git a/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts b/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts new file mode 100644 index 0000000000000..1fc41874fe9d5 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/cases/alerts/get_cases.ts @@ -0,0 +1,48 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import Boom from '@hapi/boom'; + +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; +import { CASE_ALERTS_URL } from '../../../../../common/constants'; + +export function initGetCaseIdsByAlertIdApi({ caseService, router, logger }: RouteDeps) { + router.get( + { + path: CASE_ALERTS_URL, + validate: { + params: schema.object({ + alert_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const alertId = request.params.alert_id; + if (alertId == null || alertId === '') { + throw Boom.badRequest('The `alertId` is not valid'); + } + const client = context.core.savedObjects.client; + const caseIds = await caseService.getCaseIdsByAlertId({ + client, + alertId, + }); + + return response.ok({ + body: caseIds, + }); + } catch (error) { + logger.error( + `Failed to retrieve case ids for this alert id: ${request.params.alert_id}: ${error}` + ); + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/cases/server/routes/api/index.ts b/x-pack/plugins/cases/server/routes/api/index.ts index c5b7aa85dc33e..a1635254dd09b 100644 --- a/x-pack/plugins/cases/server/routes/api/index.ts +++ b/x-pack/plugins/cases/server/routes/api/index.ts @@ -38,6 +38,7 @@ import { initPatchSubCasesApi } from './cases/sub_case/patch_sub_cases'; import { initFindSubCasesApi } from './cases/sub_case/find_sub_cases'; import { initDeleteSubCasesApi } from './cases/sub_case/delete_sub_cases'; import { ENABLE_CASE_CONNECTOR } from '../../../common/constants'; +import { initGetCaseIdsByAlertIdApi } from './cases/alerts/get_cases'; /** * Default page number when interacting with the saved objects API. @@ -86,4 +87,6 @@ export function initCaseApi(deps: RouteDeps) { initGetCasesStatusApi(deps); // Tags initGetTagsApi(deps); + // Alerts + initGetCaseIdsByAlertIdApi(deps); } diff --git a/x-pack/plugins/cases/server/services/index.ts b/x-pack/plugins/cases/server/services/index.ts index a27a8860e96b5..11b8cef6ab5a5 100644 --- a/x-pack/plugins/cases/server/services/index.ts +++ b/x-pack/plugins/cases/server/services/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { AggregationContainer } from '@elastic/elasticsearch/api/types'; import { KibanaRequest, Logger, @@ -17,6 +18,7 @@ import { SavedObjectsBulkResponse, SavedObjectsFindResult, } from 'kibana/server'; +import { nodeBuilder } from '../../../../../src/plugins/data/common'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; import { @@ -34,6 +36,7 @@ import { CaseResponse, caseTypeField, CasesFindRequest, + GetCaseIdsByAlertIdAggs, } from '../../common'; import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; import { defaultPage, defaultPerPage } from '../routes/api'; @@ -113,6 +116,10 @@ interface GetCommentArgs extends ClientArgs { commentId: string; } +interface GetCaseIdsByAlertIdArgs extends ClientArgs { + alertId: string; +} + interface PostCaseArgs extends ClientArgs { attributes: ESCaseAttributes; } @@ -220,6 +227,7 @@ export interface CaseServiceSetup { getSubCases(args: GetSubCasesArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; + getCaseIdsByAlertId(args: GetCaseIdsByAlertIdArgs): Promise; getTags(args: ClientArgs): Promise; getReporters(args: ClientArgs): Promise; getUser(args: GetUserArgs): Promise; @@ -899,6 +907,56 @@ export class CaseService implements CaseServiceSetup { } } + private buildCaseIdsAggs = (size: number = 100): Record => ({ + references: { + nested: { + path: `${CASE_COMMENT_SAVED_OBJECT}.references`, + }, + aggregations: { + caseIds: { + terms: { + field: `${CASE_COMMENT_SAVED_OBJECT}.references.id`, + size, + }, + }, + }, + }, + }); + + public async getCaseIdsByAlertId({ + client, + alertId, + }: GetCaseIdsByAlertIdArgs): Promise { + try { + this.log.debug(`Attempting to GET all cases for alert id ${alertId}`); + + let response = await client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + fields: [], + page: 1, + perPage: 1, + sortField: defaultSortField, + aggs: this.buildCaseIdsAggs(), + filter: nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, alertId), + }); + if (response.total > 100) { + response = await client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + fields: [], + page: 1, + perPage: 1, + sortField: defaultSortField, + aggs: this.buildCaseIdsAggs(response.total), + filter: nodeBuilder.is(`${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, alertId), + }); + } + return response.aggregations?.references.caseIds.buckets.map((b) => b.key) ?? []; + } catch (error) { + this.log.error(`Error on GET all cases for alert id ${alertId}: ${error}`); + throw error; + } + } + /** * Default behavior is to retrieve all comments that adhere to a given filter (if one is included). * to override this pass in the either the page or perPage options. diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index 51eb0bbb1a7e4..d67a297508b14 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -31,6 +31,7 @@ export const createCaseServiceMock = (): CaseServiceMock => ({ getAllSubCaseComments: jest.fn(), getCase: jest.fn(), getCases: jest.fn(), + getCaseIdsByAlertId: jest.fn(), getComment: jest.fn(), getMostRecentSubCase: jest.fn(), getSubCase: jest.fn(), diff --git a/x-pack/test/case_api_integration/basic/tests/cases/alerts/get_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/alerts/get_cases.ts new file mode 100644 index 0000000000000..140fb80949a24 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/alerts/get_cases.ts @@ -0,0 +1,112 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/cases/common/constants'; +import { postCaseReq, postCommentAlertReq } from '../../../../common/lib/mock'; +import { deleteAllCaseItems } from '../../../../common/lib/utils'; +import { CaseResponse } from '../../../../../../plugins/cases/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_cases using alertID', () => { + const createCase = async () => { + const { body } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + return body; + }; + + const createComment = async (caseID: string) => { + await supertest + .post(`${CASES_URL}/${caseID}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentAlertReq) + .expect(200); + }; + + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + it('should return all cases with the same alert ID attached to them', async () => { + const [case1, case2, case3] = await Promise.all([createCase(), createCase(), createCase()]); + + await Promise.all([ + createComment(case1.id), + createComment(case2.id), + createComment(case3.id), + ]); + + const { body: caseIDsWithAlert } = await supertest + .get(`${CASES_URL}/alerts/test-id`) + .expect(200); + + expect(caseIDsWithAlert.length).to.eql(3); + expect(caseIDsWithAlert).to.contain(case1.id); + expect(caseIDsWithAlert).to.contain(case2.id); + expect(caseIDsWithAlert).to.contain(case3.id); + }); + + it('should return all cases with the same alert ID when more than 100 cases', async () => { + // if there are more than 100 responses, the implementation sets the aggregation size to the + // specific value + const numCases = 102; + const createCasePromises: Array> = []; + for (let i = 0; i < numCases; i++) { + createCasePromises.push(createCase()); + } + + const cases = await Promise.all(createCasePromises); + + const commentPromises: Array> = []; + for (const caseInfo of cases) { + commentPromises.push(createComment(caseInfo.id)); + } + + await Promise.all(commentPromises); + + const { body: caseIDsWithAlert } = await supertest + .get(`${CASES_URL}/alerts/test-id`) + .expect(200); + + expect(caseIDsWithAlert.length).to.eql(numCases); + + for (const caseInfo of cases) { + expect(caseIDsWithAlert).to.contain(caseInfo.id); + } + }); + + it('should return no cases when the alert ID is not found', async () => { + const [case1, case2, case3] = await Promise.all([createCase(), createCase(), createCase()]); + + await Promise.all([ + createComment(case1.id), + createComment(case2.id), + createComment(case3.id), + ]); + + const { body: caseIDsWithAlert } = await supertest + .get(`${CASES_URL}/alerts/test-id100`) + .expect(200); + + expect(caseIDsWithAlert.length).to.eql(0); + }); + + it('should return a 302 when passing an empty alertID', async () => { + // kibana returns a 302 instead of a 400 when a url param is missing + await supertest.get(`${CASES_URL}/alerts/`).expect(302); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts index 837e6503084a7..24e6b12138895 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -13,6 +13,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { // Fastest ciGroup for the moment. this.tags('ciGroup5'); + loadTestFile(require.resolve('./cases/alerts/get_cases')); loadTestFile(require.resolve('./cases/comments/delete_comment')); loadTestFile(require.resolve('./cases/comments/find_comments')); loadTestFile(require.resolve('./cases/comments/get_comment'));