Skip to content

Commit

Permalink
[RAM] Add alert summary API (#146709)
Browse files Browse the repository at this point in the history
## Summary

Resolve: #141487


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
  • Loading branch information
XavierM authored Dec 16, 2022
1 parent e43ccd1 commit 3917bf5
Show file tree
Hide file tree
Showing 14 changed files with 614 additions and 7 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const createAlertsClientMock = () => {
find: jest.fn(),
getFeatureIdsByRegistrationContexts: jest.fn(),
getBrowserFields: jest.fn(),
getAlertSummary: jest.fn(),
};
return mocked;
};
Expand Down
124 changes: 124 additions & 0 deletions x-pack/plugins/rule_registry/server/alert_data_client/alerts_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ import { Filter, buildEsQuery, EsQueryConfig } from '@kbn/es-query';
import { decodeVersion, encodeHitVersion } from '@kbn/securitysolution-es-utils';
import {
AlertConsumers,
ALERT_TIME_RANGE,
ALERT_STATUS,
getEsQueryConfig,
getSafeSortIds,
isValidFeatureId,
STATUS_VALUES,
ValidFeatureId,
ALERT_STATUS_RECOVERED,
ALERT_END,
ALERT_STATUS_ACTIVE,
} from '@kbn/rule-data-utils';

import {
Expand All @@ -32,6 +37,7 @@ import {
import { Logger, ElasticsearchClient, EcsEventOutcome } from '@kbn/core/server';
import { AuditLogger } from '@kbn/security-plugin/server';
import { IndexPatternsFetcher } from '@kbn/data-plugin/server';
import { isEmpty } from 'lodash';
import { BrowserFields } from '../../common';
import { alertAuditEvent, operationAlertAuditActionMap } from './audit_events';
import {
Expand Down Expand Up @@ -92,6 +98,15 @@ interface GetAlertParams {
index?: string;
}

interface GetAlertSummaryParams {
id?: string;
gte: string;
lte: string;
featureIds: string[];
filter?: estypes.QueryDslQueryContainer[];
fixedInterval?: string;
}

interface SingleSearchAfterAndAudit {
id?: string | null | undefined;
query?: string | object | undefined;
Expand Down Expand Up @@ -500,6 +515,115 @@ export class AlertsClient {
}
}

public async getAlertSummary({
gte,
lte,
featureIds,
filter,
fixedInterval = '1m',
}: GetAlertSummaryParams) {
try {
const indexToUse = await this.getAuthorizedAlertsIndices(featureIds);

if (isEmpty(indexToUse)) {
throw Boom.badRequest('no featureIds were provided for getting alert summary');
}

// first search for the alert by id, then use the alert info to check if user has access to it
const responseAlertSum = await this.singleSearchAfterAndAudit({
index: (indexToUse ?? []).join(),
operation: ReadOperations.Get,
aggs: {
active_alerts_bucket: {
date_histogram: {
field: ALERT_TIME_RANGE,
fixed_interval: fixedInterval,
hard_bounds: {
min: gte,
max: lte,
},
extended_bounds: {
min: gte,
max: lte,
},
min_doc_count: 0,
},
},
recovered_alerts: {
filter: {
term: {
[ALERT_STATUS]: ALERT_STATUS_RECOVERED,
},
},
aggs: {
container: {
date_histogram: {
field: ALERT_END,
fixed_interval: fixedInterval,
extended_bounds: {
min: gte,
max: lte,
},
min_doc_count: 0,
},
},
},
},
count: {
terms: { field: ALERT_STATUS },
},
},
query: {
bool: {
filter: [
{
range: {
[ALERT_TIME_RANGE]: {
gt: gte,
lt: lte,
},
},
},
...(filter ? filter : []),
],
},
},
size: 0,
});

let activeAlertCount = 0;
let recoveredAlertCount = 0;
(
(responseAlertSum.aggregations?.count as estypes.AggregationsMultiBucketAggregateBase)
.buckets as estypes.AggregationsStringTermsBucketKeys[]
).forEach((b) => {
if (b.key === ALERT_STATUS_ACTIVE) {
activeAlertCount = b.doc_count;
} else if (b.key === ALERT_STATUS_RECOVERED) {
recoveredAlertCount = b.doc_count;
}
});

return {
activeAlertCount,
recoveredAlertCount,
activeAlerts:
(
responseAlertSum.aggregations
?.active_alerts_bucket as estypes.AggregationsAutoDateHistogramAggregate
)?.buckets ?? [],
recoveredAlerts:
(
(responseAlertSum.aggregations?.recovered_alerts as estypes.AggregationsFilterAggregate)
?.container as estypes.AggregationsAutoDateHistogramAggregate
)?.buckets ?? [],
};
} catch (error) {
this.logger.error(`getAlertSummary threw an error: ${error}`);
throw error;
}
}

public async update<Params extends RuleTypeParams = never>({
id,
status,
Expand Down
116 changes: 116 additions & 0 deletions x-pack/plugins/rule_registry/server/routes/get_alert_summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* 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 { BASE_RAC_ALERTS_API_PATH } from '../../common/constants';
import { getAlertSummaryRoute } from './get_alert_summary';
import { requestContextMock } from './__mocks__/request_context';
import { requestMock, serverMock } from './__mocks__/server';

describe('getAlertSummaryRoute', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();

beforeEach(async () => {
server = serverMock.create();
({ clients, context } = requestContextMock.createTools());

clients.rac.getAlertSummary.mockResolvedValue({
activeAlertCount: 0,
recoveredAlertCount: 0,
activeAlerts: [],
recoveredAlerts: [],
});

getAlertSummaryRoute(server.router);
});

describe('request validation', () => {
test('rejects invalid query params', async () => {
await expect(
server.inject(
requestMock.create({
method: 'post',
path: `${BASE_RAC_ALERTS_API_PATH}/_alert_summary`,
body: { gte: 4, lte: 3, featureIds: ['logs'] },
}),
context
)
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Request was rejected with message: 'Invalid value \\"4\\" supplied to \\"gte\\",Invalid value \\"3\\" supplied to \\"lte\\"'"`
);
});

test('validate gte/lte format', async () => {
const resp = await server.inject(
requestMock.create({
method: 'post',
path: `${BASE_RAC_ALERTS_API_PATH}/_alert_summary`,
body: {
gte: '2020-12-16T15:00:00.000Z',
lte: '2020-12-16',
featureIds: ['logs'],
},
}),
context
);
expect(resp.status).toEqual(400);
expect(resp.body).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"success": false,
},
"message": "gte and/or lte are not following the UTC format",
}
`);
});

test('validate fixed_interval ', async () => {
const resp = await server.inject(
requestMock.create({
method: 'post',
path: `${BASE_RAC_ALERTS_API_PATH}/_alert_summary`,
body: {
gte: '2020-12-16T15:00:00.000Z',
lte: '2020-12-16T16:00:00.000Z',
featureIds: ['logs'],
fixed_interval: 'xx',
},
}),
context
);
expect(resp.status).toEqual(400);
expect(resp.body).toMatchInlineSnapshot(`
Object {
"attributes": Object {
"success": false,
},
"message": "fixed_interval is not following the expected format 1m, 1h, 1d, 1w",
}
`);
});

test('rejects unknown query params', async () => {
await expect(
server.inject(
requestMock.create({
method: 'post',
path: `${BASE_RAC_ALERTS_API_PATH}/_alert_summary`,
body: {
gte: '2020-12-16T15:00:00.000Z',
lte: '2020-12-16T16:00:00.000Z',
featureIds: ['logs'],
boop: 'unknown',
},
}),
context
)
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Request was rejected with message: 'invalid keys \\"boop\\"'"`
);
});
});
});
97 changes: 97 additions & 0 deletions x-pack/plugins/rule_registry/server/routes/get_alert_summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* 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 Boom from '@hapi/boom';
import { IRouter } from '@kbn/core/server';
import * as t from 'io-ts';
import { transformError } from '@kbn/securitysolution-es-utils';
import moment from 'moment';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';

import { RacRequestHandlerContext } from '../types';
import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants';
import { buildRouteValidation } from './utils/route_validation';

export const getAlertSummaryRoute = (router: IRouter<RacRequestHandlerContext>) => {
router.post(
{
path: `${BASE_RAC_ALERTS_API_PATH}/_alert_summary`,
validate: {
body: buildRouteValidation(
t.intersection([
t.exact(
t.type({
gte: t.string,
lte: t.string,
featureIds: t.array(t.string),
})
),
t.exact(
t.partial({
fixed_interval: t.string,
filter: t.array(t.object),
})
),
])
),
},
options: {
tags: ['access:rac'],
},
},
async (context, request, response) => {
try {
const racContext = await context.rac;
const alertsClient = await racContext.getAlertsClient();
const { gte, lte, featureIds, filter, fixed_interval: fixedInterval } = request.body;
if (
!(
moment(gte, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true).isValid() &&
moment(lte, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true).isValid()
)
) {
throw Boom.badRequest('gte and/or lte are not following the UTC format');
}

if (fixedInterval && fixedInterval?.match(/^\d{1,2}['m','h','d','w']$/) == null) {
throw Boom.badRequest(
'fixed_interval is not following the expected format 1m, 1h, 1d, 1w'
);
}

const aggs = await alertsClient.getAlertSummary({
gte,
lte,
featureIds,
filter: filter as estypes.QueryDslQueryContainer[],
fixedInterval,
});
return response.ok({
body: aggs,
});
} catch (exc) {
const err = transformError(exc);
const contentType = {
'content-type': 'application/json',
};
const defaultedHeaders = {
...contentType,
};
return response.customError({
headers: defaultedHeaders,
statusCode: err.statusCode,
body: {
message: err.message,
attributes: {
success: false,
},
},
});
}
}
);
};
2 changes: 2 additions & 0 deletions x-pack/plugins/rule_registry/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { bulkUpdateAlertsRoute } from './bulk_update_alerts';
import { findAlertsByQueryRoute } from './find';
import { getFeatureIdsByRegistrationContexts } from './get_feature_ids_by_registration_contexts';
import { getBrowserFieldsByFeatureId } from './get_browser_fields_by_feature_id';
import { getAlertSummaryRoute } from './get_alert_summary';

export function defineRoutes(router: IRouter<RacRequestHandlerContext>) {
getAlertByIdRoute(router);
Expand All @@ -23,4 +24,5 @@ export function defineRoutes(router: IRouter<RacRequestHandlerContext>) {
findAlertsByQueryRoute(router);
getFeatureIdsByRegistrationContexts(router);
getBrowserFieldsByFeatureId(router);
getAlertSummaryRoute(router);
}
Loading

0 comments on commit 3917bf5

Please sign in to comment.