Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RAM] Add alert summary API #146709

Merged
merged 19 commits into from
Dec 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this option do?

Copy link
Contributor Author

@XavierM XavierM Dec 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for kibana security, I think we created a special rac access.

},
},
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() &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use DateFromString instead to validate it at the body validation level?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only imagined that will use this UTC format and not just a date. However, if you feel that we should allow just date without time, I can change it back to moment.ISO_8601

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it is fine, I also used the same time format in my PR for the related component.

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we validate this with a custom validator on the body params?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not find an easy way to do it with io-ts, so I will keep it this way for now.

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