[RAM] Modify rules client aggregate method to allow for custom aggregations (#149966)

## Summary
Resolves: https://github.com/elastic/kibana/issues/131682

Taking inspiration from this PR: https://github.com/elastic/kibana/pull/148382

This PR changes the rules client aggregation method to accept arbitrary aggregations. This allows the users of the method to better customize the response when calling rules client aggregate. We have added validation to the aggregate method to prevent aggregations on certain rule fields.

```ts
const ALLOW_FIELDS = [
  'alert.attributes.executionStatus.status',
  'alert.attributes.lastRun.outcome',
  'alert.attributes.muteAll',
  'alert.attributes.tags',
  'alert.attributes.snoozeSchedule',
  'alert.attributes.snoozeSchedule.duration',
  'alert.attributes.alertTypeId',
  'alert.attributes.enabled',
  'alert.attributes.params.*',
];

const ALLOW_AGG_TYPES = ['terms', 'composite', 'nested', 'filter'];
```

This PR also adds a new endpoint `rules/_tags` to allow for pagination of rule tags. This new endpoint takes advantage of the arbitrary aggregation of the new aggregate method. 

I tested the security solution rules page to ensure tags are still loading as expected.

### 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

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Maxim Palenov <maxim.palenov@elastic.co>
This commit is contained in:
Jiawei Wu 2023-03-08 11:28:05 -08:00 committed by GitHub
parent 7176d8c757
commit 50e2723268
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1737 additions and 343 deletions

View file

@ -162,6 +162,8 @@ const histogramSchema = s.object({
});
const compositeSchema = s.object({
size: s.maybe(s.number()),
after: s.maybe(s.recordOf(s.string(), s.nullable(s.oneOf([s.string(), s.number()])))),
sources: s.arrayOf(
s.recordOf(
s.string(),

View file

@ -0,0 +1,114 @@
/*
* 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 {
getDefaultRuleAggregation,
formatDefaultAggregationResult,
} from './default_rule_aggregation';
describe('getDefaultRuleAggregation', () => {
it('should return aggregation with default maxTags', () => {
const result = getDefaultRuleAggregation();
expect(result.tags).toEqual({
terms: { field: 'alert.attributes.tags', order: { _key: 'asc' }, size: 50 },
});
});
it('should return aggregation with custom maxTags', () => {
const result = getDefaultRuleAggregation({ maxTags: 100 });
expect(result.tags).toEqual({
terms: { field: 'alert.attributes.tags', order: { _key: 'asc' }, size: 100 },
});
});
});
describe('formatDefaultAggregationResult', () => {
it('should format aggregation result', () => {
const result = formatDefaultAggregationResult({
status: {
buckets: [
{ key: 'active', doc_count: 8 },
{ key: 'error', doc_count: 6 },
{ key: 'ok', doc_count: 10 },
{ key: 'pending', doc_count: 4 },
{ key: 'unknown', doc_count: 2 },
{ key: 'warning', doc_count: 1 },
],
},
outcome: {
buckets: [
{ key: 'succeeded', doc_count: 2 },
{ key: 'failed', doc_count: 4 },
{ key: 'warning', doc_count: 6 },
],
},
enabled: {
buckets: [
{ key: 0, key_as_string: '0', doc_count: 2 },
{ key: 1, key_as_string: '1', doc_count: 28 },
],
},
muted: {
buckets: [
{ key: 0, key_as_string: '0', doc_count: 27 },
{ key: 1, key_as_string: '1', doc_count: 3 },
],
},
snoozed: {
count: {
doc_count: 5,
},
},
tags: {
buckets: [
{
key: 'a',
doc_count: 10,
},
{
key: 'b',
doc_count: 20,
},
{
key: 'c',
doc_count: 30,
},
],
},
});
expect(result).toEqual(
expect.objectContaining({
ruleExecutionStatus: {
active: 8,
error: 6,
ok: 10,
pending: 4,
unknown: 2,
warning: 1,
},
ruleLastRunOutcome: {
succeeded: 2,
failed: 4,
warning: 6,
},
ruleEnabledStatus: {
enabled: 28,
disabled: 2,
},
ruleMutedStatus: {
muted: 3,
unmuted: 27,
},
ruleSnoozedStatus: {
snoozed: 5,
},
ruleTags: ['a', 'b', 'c'],
})
);
});
});

View file

@ -0,0 +1,172 @@
/*
* 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 type { AggregationsAggregationContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
RuleExecutionStatusValues,
RuleLastRunOutcomeValues,
RuleAggregationFormattedResult,
} from './rule';
export interface DefaultRuleAggregationResult {
status: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
outcome: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
muted: {
buckets: Array<{
key: number;
key_as_string: string;
doc_count: number;
}>;
};
enabled: {
buckets: Array<{
key: number;
key_as_string: string;
doc_count: number;
}>;
};
snoozed: {
count: {
doc_count: number;
};
};
tags: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
}
interface GetDefaultRuleAggregationParams {
maxTags?: number;
}
export const getDefaultRuleAggregation = (
params?: GetDefaultRuleAggregationParams
): Record<string, AggregationsAggregationContainer> => {
const { maxTags = 50 } = params || {};
return {
status: {
terms: { field: 'alert.attributes.executionStatus.status' },
},
outcome: {
terms: { field: 'alert.attributes.lastRun.outcome' },
},
enabled: {
terms: { field: 'alert.attributes.enabled' },
},
muted: {
terms: { field: 'alert.attributes.muteAll' },
},
tags: {
terms: { field: 'alert.attributes.tags', order: { _key: 'asc' }, size: maxTags },
},
snoozed: {
nested: {
path: 'alert.attributes.snoozeSchedule',
},
aggs: {
count: {
filter: {
exists: {
field: 'alert.attributes.snoozeSchedule.duration',
},
},
},
},
},
};
};
export const formatDefaultAggregationResult = (
aggregations: DefaultRuleAggregationResult
): RuleAggregationFormattedResult => {
if (!aggregations) {
// Return a placeholder with all zeroes
const placeholder: RuleAggregationFormattedResult = {
ruleExecutionStatus: {},
ruleLastRunOutcome: {},
ruleEnabledStatus: {
enabled: 0,
disabled: 0,
},
ruleMutedStatus: {
muted: 0,
unmuted: 0,
},
ruleSnoozedStatus: { snoozed: 0 },
ruleTags: [],
};
for (const key of RuleExecutionStatusValues) {
placeholder.ruleExecutionStatus[key] = 0;
}
return placeholder;
}
const ruleExecutionStatus = aggregations.status.buckets.map(({ key, doc_count: docCount }) => ({
[key]: docCount,
}));
const ruleLastRunOutcome = aggregations.outcome.buckets.map(({ key, doc_count: docCount }) => ({
[key]: docCount,
}));
const enabledBuckets = aggregations.enabled.buckets;
const mutedBuckets = aggregations.muted.buckets;
const result: RuleAggregationFormattedResult = {
ruleExecutionStatus: ruleExecutionStatus.reduce(
(acc, curr: { [status: string]: number }) => Object.assign(acc, curr),
{}
),
ruleLastRunOutcome: ruleLastRunOutcome.reduce(
(acc, curr: { [status: string]: number }) => Object.assign(acc, curr),
{}
),
ruleEnabledStatus: {
enabled: enabledBuckets.find((bucket) => bucket.key === 1)?.doc_count ?? 0,
disabled: enabledBuckets.find((bucket) => bucket.key === 0)?.doc_count ?? 0,
},
ruleMutedStatus: {
muted: mutedBuckets.find((bucket) => bucket.key === 1)?.doc_count ?? 0,
unmuted: mutedBuckets.find((bucket) => bucket.key === 0)?.doc_count ?? 0,
},
ruleSnoozedStatus: {
snoozed: aggregations.snoozed?.count?.doc_count ?? 0,
},
ruleTags: [],
};
// Fill missing keys with zeroes
for (const key of RuleExecutionStatusValues) {
if (!result.ruleExecutionStatus.hasOwnProperty(key)) {
result.ruleExecutionStatus[key] = 0;
}
}
for (const key of RuleLastRunOutcomeValues) {
if (!result.ruleLastRunOutcome.hasOwnProperty(key)) {
result.ruleLastRunOutcome[key] = 0;
}
}
const tagsBuckets = aggregations.tags?.buckets || [];
result.ruleTags = tagsBuckets.map((bucket) => bucket.key);
return result;
};

View file

@ -23,6 +23,8 @@ export * from './rule_notify_when_type';
export * from './parse_duration';
export * from './execution_log_types';
export * from './rule_snooze_type';
export * from './default_rule_aggregation';
export * from './rule_tags_aggregation';
export { mappingFromFieldMap, getComponentTemplateFromFieldMap } from './alert_schema';

View file

@ -10,6 +10,7 @@ import type {
SavedObjectAttributes,
SavedObjectsResolveResponse,
} from '@kbn/core/server';
import type { KueryNode } from '@kbn/es-query';
import { RuleNotifyWhenType } from './rule_notify_when_type';
import { RuleSnooze } from './rule_snooze_type';
@ -89,8 +90,21 @@ export interface RuleAction {
};
}
export interface RuleAggregations {
alertExecutionStatus: { [status: string]: number };
export interface AggregateOptions {
search?: string;
defaultSearchOperator?: 'AND' | 'OR';
searchFields?: string[];
hasReference?: {
type: string;
id: string;
};
filter?: string | KueryNode;
page?: number;
perPage?: number;
}
export interface RuleAggregationFormattedResult {
ruleExecutionStatus: { [status: string]: number };
ruleLastRunOutcome: { [status: string]: number };
ruleEnabledStatus: { enabled: number; disabled: number };
ruleMutedStatus: { muted: number; unmuted: number };

View file

@ -0,0 +1,93 @@
/*
* 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 { getRuleTagsAggregation, formatRuleTagsAggregationResult } from './rule_tags_aggregation';
describe('getRuleTagsAggregation', () => {
it('should return aggregation with default params', () => {
const result = getRuleTagsAggregation();
expect(result.tags).toEqual({
composite: {
size: 50,
sources: [
{
tags: {
terms: {
field: 'alert.attributes.tags',
order: 'asc',
},
},
},
],
},
});
});
it('should return aggregation with custom maxTags', () => {
const result = getRuleTagsAggregation({
maxTags: 100,
after: {
tags: 'e',
},
});
expect(result.tags).toEqual({
composite: {
size: 100,
after: {
tags: 'e',
},
sources: [
{
tags: {
terms: {
field: 'alert.attributes.tags',
order: 'asc',
},
},
},
],
},
});
});
});
describe('formatRuleTagsAggregationResult', () => {
it('should format aggregation result', () => {
const result = formatRuleTagsAggregationResult({
tags: {
buckets: [
{
key: {
tags: 'a',
},
doc_count: 1,
},
{
key: {
tags: 'b',
},
doc_count: 2,
},
{
key: {
tags: 'c',
},
doc_count: 3,
},
{
key: {
tags: 'd',
},
doc_count: 4,
},
],
},
});
expect(result.ruleTags).toEqual(['a', 'b', 'c', 'd']);
});
});

View file

@ -0,0 +1,76 @@
/*
* 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 type {
AggregationsAggregationContainer,
AggregationsCompositeAggregation,
AggregationsAggregateOrder,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { AggregateOptions } from './rule';
export type RuleTagsAggregationOptions = Pick<AggregateOptions, 'filter' | 'search'> & {
after?: AggregationsCompositeAggregation['after'];
maxTags?: number;
};
export interface RuleTagsAggregationFormattedResult {
ruleTags: string[];
}
export interface RuleTagsAggregationResult {
tags: {
buckets: Array<{
key: {
tags: string;
};
doc_count: number;
}>;
};
}
interface GetRuleTagsAggregationParams {
maxTags?: number;
after?: AggregationsCompositeAggregation['after'];
}
export const getRuleTagsAggregation = (
params?: GetRuleTagsAggregationParams
): Record<string, AggregationsAggregationContainer> => {
const { maxTags = 50, after } = params || {};
return {
tags: {
composite: {
...(after ? { after } : {}),
size: maxTags,
sources: [
{
tags: {
terms: {
field: 'alert.attributes.tags',
order: 'asc' as unknown as AggregationsAggregateOrder,
},
},
},
],
},
},
};
};
export const formatRuleTagsAggregationResult = (
aggregations: RuleTagsAggregationResult
): RuleTagsAggregationFormattedResult => {
if (!aggregations) {
return {
ruleTags: [],
};
}
const tagsBuckets = aggregations.tags.buckets || [];
return {
ruleTags: tagsBuckets.map((bucket) => bucket.key.tags),
};
};

View file

@ -30,6 +30,103 @@ beforeEach(() => {
jest.resetAllMocks();
});
const aggregateResult = {
status: {
buckets: [
{
key: 'ok',
doc_count: 15,
},
{
key: 'error',
doc_count: 2,
},
{
key: 'active',
doc_count: 23,
},
{
key: 'pending',
doc_count: 1,
},
{
key: 'unknown',
doc_count: 0,
},
{
key: 'warning',
doc_count: 10,
},
],
},
outcome: {
buckets: [
{
key: 'succeeded',
doc_count: 2,
},
{
key: 'failed',
doc_count: 4,
},
{
key: 'warning',
doc_count: 6,
},
],
},
enabled: {
buckets: [
{
key: 0,
key_as_string: '0',
doc_count: 2,
},
{
key: 1,
key_as_string: '1',
doc_count: 28,
},
],
},
muted: {
buckets: [
{
key: 0,
key_as_string: '0',
doc_count: 27,
},
{
key: 1,
key_as_string: '1',
doc_count: 3,
},
],
},
snoozed: {
doc_count: 0,
count: {
doc_count: 0,
},
},
tags: {
buckets: [
{
key: 'a',
doc_count: 10,
},
{
key: 'b',
doc_count: 20,
},
{
key: 'c',
doc_count: 30,
},
],
},
};
describe('aggregateRulesRoute', () => {
it('aggregate rules with proper parameters', async () => {
const licenseState = licenseStateMock.create();
@ -41,32 +138,6 @@ describe('aggregateRulesRoute', () => {
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rules/_aggregate"`);
const aggregateResult = {
alertExecutionStatus: {
ok: 15,
error: 2,
active: 23,
pending: 1,
unknown: 0,
},
ruleLastRunOutcome: {
succeeded: 1,
failed: 2,
warning: 3,
},
ruleEnabledStatus: {
disabled: 1,
enabled: 40,
},
ruleMutedStatus: {
muted: 2,
unmuted: 39,
},
ruleSnoozedStatus: {
snoozed: 4,
},
ruleTags: ['a', 'b', 'c'],
};
rulesClient.aggregate.mockResolvedValueOnce(aggregateResult);
const [context, req, res] = mockHandlerArguments(
@ -83,8 +154,8 @@ describe('aggregateRulesRoute', () => {
Object {
"body": Object {
"rule_enabled_status": Object {
"disabled": 1,
"enabled": 40,
"disabled": 2,
"enabled": 28,
},
"rule_execution_status": Object {
"active": 23,
@ -92,18 +163,19 @@ describe('aggregateRulesRoute', () => {
"ok": 15,
"pending": 1,
"unknown": 0,
"warning": 10,
},
"rule_last_run_outcome": Object {
"failed": 2,
"succeeded": 1,
"warning": 3,
"failed": 4,
"succeeded": 2,
"warning": 6,
},
"rule_muted_status": Object {
"muted": 2,
"unmuted": 39,
"muted": 3,
"unmuted": 27,
},
"rule_snoozed_status": Object {
"snoozed": 4,
"snoozed": 0,
},
"rule_tags": Array [
"a",
@ -118,6 +190,51 @@ describe('aggregateRulesRoute', () => {
expect(rulesClient.aggregate.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"aggs": Object {
"enabled": Object {
"terms": Object {
"field": "alert.attributes.enabled",
},
},
"muted": Object {
"terms": Object {
"field": "alert.attributes.muteAll",
},
},
"outcome": Object {
"terms": Object {
"field": "alert.attributes.lastRun.outcome",
},
},
"snoozed": Object {
"aggs": Object {
"count": Object {
"filter": Object {
"exists": Object {
"field": "alert.attributes.snoozeSchedule.duration",
},
},
},
},
"nested": Object {
"path": "alert.attributes.snoozeSchedule",
},
},
"status": Object {
"terms": Object {
"field": "alert.attributes.executionStatus.status",
},
},
"tags": Object {
"terms": Object {
"field": "alert.attributes.tags",
"order": Object {
"_key": "asc",
},
"size": 50,
},
},
},
"options": Object {
"defaultSearchOperator": "AND",
},
@ -128,8 +245,8 @@ describe('aggregateRulesRoute', () => {
expect(res.ok).toHaveBeenCalledWith({
body: {
rule_enabled_status: {
disabled: 1,
enabled: 40,
disabled: 2,
enabled: 28,
},
rule_execution_status: {
ok: 15,
@ -137,18 +254,19 @@ describe('aggregateRulesRoute', () => {
active: 23,
pending: 1,
unknown: 0,
warning: 10,
},
rule_last_run_outcome: {
succeeded: 1,
failed: 2,
warning: 3,
failed: 4,
succeeded: 2,
warning: 6,
},
rule_muted_status: {
muted: 2,
unmuted: 39,
muted: 3,
unmuted: 27,
},
rule_snoozed_status: {
snoozed: 4,
snoozed: 0,
},
rule_tags: ['a', 'b', 'c'],
},
@ -163,20 +281,7 @@ describe('aggregateRulesRoute', () => {
const [, handler] = router.get.mock.calls[0];
rulesClient.aggregate.mockResolvedValueOnce({
alertExecutionStatus: {
ok: 15,
error: 2,
active: 23,
pending: 1,
unknown: 0,
},
ruleLastRunOutcome: {
succeeded: 2,
failed: 4,
warning: 6,
},
});
rulesClient.aggregate.mockResolvedValueOnce(aggregateResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
@ -221,22 +326,10 @@ describe('aggregateRulesRoute', () => {
const router = httpServiceMock.createRouter();
aggregateRulesRoute(router, licenseState, mockUsageCounter);
const aggregateResult = {
alertExecutionStatus: {
ok: 15,
error: 2,
active: 23,
pending: 1,
unknown: 0,
},
ruleLastRunOutcome: {
succeeded: 2,
failed: 4,
warning: 6,
},
};
rulesClient.aggregate.mockResolvedValueOnce(aggregateResult);
const [, handler] = router.get.mock.calls[0];
rulesClient.aggregate.mockResolvedValueOnce(aggregateResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{

View file

@ -8,8 +8,14 @@
import { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { UsageCounter } from '@kbn/usage-collection-plugin/server';
import {
AggregateOptions,
DefaultRuleAggregationResult,
formatDefaultAggregationResult,
getDefaultRuleAggregation,
RuleAggregationFormattedResult,
} from '../../common';
import { ILicenseState } from '../lib';
import { AggregateResult, AggregateOptions } from '../rules_client';
import { RewriteResponseCase, RewriteRequestCase, verifyAccessAndContext } from './lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
import { trackLegacyTerminology } from './lib/track_legacy_terminology';
@ -45,8 +51,8 @@ const rewriteQueryReq: RewriteRequestCase<AggregateOptions> = ({
...(hasReference ? { hasReference } : {}),
...(searchFields ? { searchFields } : {}),
});
const rewriteBodyRes: RewriteResponseCase<AggregateResult> = ({
alertExecutionStatus,
const rewriteBodyRes: RewriteResponseCase<RuleAggregationFormattedResult> = ({
ruleExecutionStatus,
ruleLastRunOutcome,
ruleEnabledStatus,
ruleMutedStatus,
@ -55,7 +61,7 @@ const rewriteBodyRes: RewriteResponseCase<AggregateResult> = ({
...rest
}) => ({
...rest,
rule_execution_status: alertExecutionStatus,
rule_execution_status: ruleExecutionStatus,
rule_last_run_outcome: ruleLastRunOutcome,
rule_enabled_status: ruleEnabledStatus,
rule_muted_status: ruleMutedStatus,
@ -86,9 +92,12 @@ export const aggregateRulesRoute = (
[req.query.search, req.query.search_fields].filter(Boolean) as string[],
usageCounter
);
const aggregateResult = await rulesClient.aggregate({ options });
const aggregateResult = await rulesClient.aggregate<DefaultRuleAggregationResult>({
aggs: getDefaultRuleAggregation(),
options,
});
return res.ok({
body: rewriteBodyRes(aggregateResult),
body: rewriteBodyRes(formatDefaultAggregationResult(aggregateResult)),
});
})
)
@ -111,9 +120,12 @@ export const aggregateRulesRoute = (
[req.body.search, req.body.search_fields].filter(Boolean) as string[],
usageCounter
);
const aggregateResult = await rulesClient.aggregate({ options });
const aggregateResult = await rulesClient.aggregate<DefaultRuleAggregationResult>({
aggs: getDefaultRuleAggregation(),
options,
});
return res.ok({
body: rewriteBodyRes(aggregateResult),
body: rewriteBodyRes(formatDefaultAggregationResult(aggregateResult)),
});
})
)

View file

@ -0,0 +1,170 @@
/*
* 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 { httpServiceMock } from '@kbn/core/server/mocks';
import { licenseStateMock } from '../lib/license_state.mock';
import { verifyApiAccess } from '../lib/license_api_access';
import { mockHandlerArguments } from './_mock_handler_arguments';
import { rulesClientMock } from '../rules_client.mock';
import { getRuleTagsRoute } from './get_rule_tags';
import {} from '../../common/rule_tags_aggregation';
const rulesClient = rulesClientMock.create();
jest.mock('../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
jest.mock('../../common/rule_tags_aggregation', () => ({
...jest.requireActual('../../common/rule_tags_aggregation'),
formatRuleTagsAggregationResult: jest.fn(),
}));
beforeEach(() => {
jest.resetAllMocks();
});
const { formatRuleTagsAggregationResult } = jest.requireMock('../../common/rule_tags_aggregation');
describe('getRuleTagsRoute', () => {
it('aggregates rule tags with proper parameters', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getRuleTagsRoute(router, licenseState);
const [config, handler] = router.get.mock.calls[0];
expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rules/_tags"`);
const aggregateResult = { ruleTags: ['a', 'b', 'c'] };
formatRuleTagsAggregationResult.mockReturnValueOnce(aggregateResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
query: {
filter: 'test',
search: 'search text',
after: {
tags: 'c',
},
},
},
['ok']
);
expect(await handler(context, req, res)).toMatchInlineSnapshot(`
Object {
"body": Object {
"rule_tags": Array [
"a",
"b",
"c",
],
},
}
`);
expect(rulesClient.aggregate).toHaveBeenCalledTimes(1);
expect(rulesClient.aggregate.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"aggs": Object {
"tags": Object {
"composite": Object {
"after": Object {
"tags": "c",
},
"size": 50,
"sources": Array [
Object {
"tags": Object {
"terms": Object {
"field": "alert.attributes.tags",
"order": "asc",
},
},
},
],
},
},
},
"options": Object {
"after": Object {
"tags": "c",
},
"defaultSearchOperator": "AND",
"filter": "test",
"search": "search text",
"searchFields": Array [
"tags",
],
},
},
]
`);
expect(res.ok).toHaveBeenCalledWith({
body: {
rule_tags: ['a', 'b', 'c'],
},
});
});
it('ensures the license allows aggregating rule tags', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getRuleTagsRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
formatRuleTagsAggregationResult.mockReturnValueOnce({ ruleTags: ['a', 'b', 'c', 'd'] });
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
{
query: {
filter: 'test',
search: 'search text',
after: {
tags: 'c',
},
},
}
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
it('ensures the license check prevents aggregating rule tags', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('OMG');
});
getRuleTagsRoute(router, licenseState);
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{},
{
query: {},
},
['ok']
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
});

View file

@ -0,0 +1,83 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import {
RuleTagsAggregationResult,
RuleTagsAggregationFormattedResult,
RuleTagsAggregationOptions,
getRuleTagsAggregation,
formatRuleTagsAggregationResult,
} from '../../common';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
import { ILicenseState } from '../lib';
import { RewriteResponseCase, RewriteRequestCase, verifyAccessAndContext } from './lib';
const querySchema = schema.object({
filter: schema.maybe(schema.string()),
search: schema.maybe(schema.string()),
after: schema.maybe(
schema.recordOf(
schema.string(),
schema.nullable(schema.oneOf([schema.string(), schema.number()]))
)
),
max_tags: schema.maybe(schema.number()),
});
const rewriteQueryReq: RewriteRequestCase<RuleTagsAggregationOptions> = ({
max_tags: maxTags,
...rest
}) => ({
...rest,
...(maxTags ? { maxTags } : {}),
});
const rewriteBodyRes: RewriteResponseCase<RuleTagsAggregationFormattedResult> = ({
ruleTags,
...rest
}) => ({
...rest,
rule_tags: ruleTags,
});
export const getRuleTagsRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.get(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_tags`,
validate: {
query: querySchema,
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const rulesClient = (await context.alerting).getRulesClient();
const options = rewriteQueryReq(req.query);
const aggregateResult = await rulesClient.aggregate<RuleTagsAggregationResult>({
options: {
...options,
defaultSearchOperator: 'AND',
searchFields: ['tags'],
},
aggs: getRuleTagsAggregation({
maxTags: options.maxTags,
after: options.after,
}),
});
return res.ok({
body: rewriteBodyRes(formatRuleTagsAggregationResult(aggregateResult)),
});
})
)
);
};

View file

@ -44,6 +44,7 @@ import { bulkDisableRulesRoute } from './bulk_disable_rules';
import { cloneRuleRoute } from './clone_rule';
import { getFlappingSettingsRoute } from './get_flapping_settings';
import { updateFlappingSettingsRoute } from './update_flapping_settings';
import { getRuleTagsRoute } from './get_rule_tags';
export interface RouteOptions {
router: IRouter<AlertingRequestHandlerContext>;
@ -91,4 +92,5 @@ export function defineRoutes(opts: RouteOptions) {
cloneRuleRoute(router, licenseState);
getFlappingSettingsRoute(router, licenseState);
updateFlappingSettingsRoute(router, licenseState);
getRuleTagsRoute(router, licenseState);
}

View file

@ -31,6 +31,13 @@ jest.mock('../lib/track_legacy_terminology', () => ({
trackLegacyTerminology: jest.fn(),
}));
jest.mock('../../../common', () => ({
...jest.requireActual('../../../common'),
formatDefaultAggregationResult: jest.fn(),
}));
const { formatDefaultAggregationResult } = jest.requireMock('../../../common');
beforeEach(() => {
jest.resetAllMocks();
});
@ -47,7 +54,7 @@ describe('aggregateAlertRoute', () => {
expect(config.path).toMatchInlineSnapshot(`"/api/alerts/_aggregate"`);
const aggregateResult = {
alertExecutionStatus: {
ruleExecutionStatus: {
ok: 15,
error: 2,
active: 23,
@ -60,7 +67,7 @@ describe('aggregateAlertRoute', () => {
warning: 3,
},
};
rulesClient.aggregate.mockResolvedValueOnce(aggregateResult);
formatDefaultAggregationResult.mockReturnValueOnce(aggregateResult);
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
@ -95,6 +102,51 @@ describe('aggregateAlertRoute', () => {
expect(rulesClient.aggregate.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"aggs": Object {
"enabled": Object {
"terms": Object {
"field": "alert.attributes.enabled",
},
},
"muted": Object {
"terms": Object {
"field": "alert.attributes.muteAll",
},
},
"outcome": Object {
"terms": Object {
"field": "alert.attributes.lastRun.outcome",
},
},
"snoozed": Object {
"aggs": Object {
"count": Object {
"filter": Object {
"exists": Object {
"field": "alert.attributes.snoozeSchedule.duration",
},
},
},
},
"nested": Object {
"path": "alert.attributes.snoozeSchedule",
},
},
"status": Object {
"terms": Object {
"field": "alert.attributes.executionStatus.status",
},
},
"tags": Object {
"terms": Object {
"field": "alert.attributes.tags",
"order": Object {
"_key": "asc",
},
"size": 50,
},
},
},
"options": Object {
"defaultSearchOperator": "AND",
},
@ -103,7 +155,10 @@ describe('aggregateAlertRoute', () => {
`);
expect(res.ok).toHaveBeenCalledWith({
body: aggregateResult,
body: {
ruleLastRunOutcome: aggregateResult.ruleLastRunOutcome,
alertExecutionStatus: aggregateResult.ruleExecutionStatus,
},
});
});
@ -115,8 +170,8 @@ describe('aggregateAlertRoute', () => {
const [, handler] = router.get.mock.calls[0];
rulesClient.aggregate.mockResolvedValueOnce({
alertExecutionStatus: {
formatDefaultAggregationResult.mockReturnValueOnce({
ruleExecutionStatus: {
ok: 15,
error: 2,
active: 23,
@ -173,6 +228,22 @@ describe('aggregateAlertRoute', () => {
const router = httpServiceMock.createRouter();
aggregateAlertRoute(router, licenseState, mockUsageCounter);
formatDefaultAggregationResult.mockReturnValueOnce({
ruleExecutionStatus: {
ok: 15,
error: 2,
active: 23,
pending: 1,
unknown: 0,
},
ruleLastRunOutcome: {
succeeded: 1,
failed: 2,
warning: 3,
},
});
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ rulesClient },
@ -192,6 +263,22 @@ describe('aggregateAlertRoute', () => {
const router = httpServiceMock.createRouter();
aggregateAlertRoute(router, licenseState, mockUsageCounter);
formatDefaultAggregationResult.mockReturnValueOnce({
ruleExecutionStatus: {
ok: 15,
error: 2,
active: 23,
pending: 1,
unknown: 0,
},
ruleLastRunOutcome: {
succeeded: 1,
failed: 2,
warning: 3,
},
});
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ rulesClient },

View file

@ -10,7 +10,12 @@ import { UsageCounter } from '@kbn/usage-collection-plugin/server';
import type { AlertingRouter } from '../../types';
import { ILicenseState } from '../../lib/license_state';
import { verifyApiAccess } from '../../lib/license_api_access';
import { LEGACY_BASE_ALERT_API_PATH } from '../../../common';
import {
LEGACY_BASE_ALERT_API_PATH,
DefaultRuleAggregationResult,
getDefaultRuleAggregation,
formatDefaultAggregationResult,
} from '../../../common';
import { renameKeys } from '../lib/rename_keys';
import { FindOptions } from '../../rules_client';
import { trackLegacyRouteUsage } from '../../lib/track_legacy_route_usage';
@ -77,9 +82,16 @@ export const aggregateAlertRoute = (
: [query.search_fields];
}
const aggregateResult = await rulesClient.aggregate({ options });
const aggregateResult = await rulesClient.aggregate<DefaultRuleAggregationResult>({
options,
aggs: getDefaultRuleAggregation(),
});
const { ruleExecutionStatus, ...rest } = formatDefaultAggregationResult(aggregateResult);
return res.ok({
body: aggregateResult,
body: {
...rest,
alertExecutionStatus: ruleExecutionStatus,
},
});
})
);

View file

@ -10,8 +10,6 @@ type RenameAlertToRule<K extends string> = K extends `alertTypeId`
? `ruleTypeId`
: K extends `alertId`
? `ruleId`
: K extends `alertExecutionStatus`
? `ruleExecutionStatus`
: K extends `actionTypeId`
? `connectorTypeId`
: K extends `alertInstanceId`

View file

@ -12,7 +12,7 @@ export type RulesClientMock = jest.Mocked<Schema>;
const createRulesClientMock = () => {
const mocked: RulesClientMock = {
aggregate: jest.fn().mockReturnValue({ alertExecutionStatus: {}, ruleLastRunOutcome: {} }),
aggregate: jest.fn().mockReturnValue({ ruleExecutionStatus: {}, ruleLastRunOutcome: {} }),
create: jest.fn(),
get: jest.fn(),
resolve: jest.fn(),

View file

@ -0,0 +1,304 @@
/*
* 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 { getRuleTagsAggregation, getDefaultRuleAggregation } from '../../../common';
import type { AggregationsAggregateOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { validateRuleAggregationFields } from './validate_rule_aggregation_fields';
describe('validateAggregationTerms', () => {
it('should allow for simple valid aggregations', () => {
expect(() => {
validateRuleAggregationFields({
name1: {
terms: {
field: 'alert.attributes.lastRun.outcome',
},
},
name2: {
terms: {
field: 'alert.attributes.tags',
},
},
});
}).not.toThrowError();
});
it('should allow for nested valid aggregations', () => {
expect(() => {
validateRuleAggregationFields({
name1: {
terms: {
field: 'alert.attributes.executionStatus.status',
},
aggs: {
nestedAggs: {
terms: {
field: 'alert.attributes.snoozeSchedule',
},
aggs: {
anotherNestedAgg: {
terms: {
field: 'alert.attributes.alertTypeId',
},
},
},
},
},
},
});
}).not.toThrowError();
});
it('should allow for nested valid aggregations with root level aggs', () => {
expect(() => {
validateRuleAggregationFields({
name1: {
composite: {
sources: [
{
tags: {
terms: {
field: 'alert.attributes.tags',
order: 'asc' as unknown as AggregationsAggregateOrder,
},
},
},
],
},
terms: {
field: 'alert.attributes.muteAll',
},
aggs: {
nestedAggs: {
nested: {
path: 'alert.attributes.snoozeSchedule',
},
terms: {
field: 'alert.attributes.enabled',
},
aggs: {
nestedAggsAgain: {
terms: {
field: 'alert.attributes.executionStatus.status',
},
},
},
},
},
},
});
}).not.toThrowError();
});
it('should allow for default and tags aggregations', () => {
expect(() => validateRuleAggregationFields(getDefaultRuleAggregation())).not.toThrowError();
expect(() => validateRuleAggregationFields(getRuleTagsAggregation())).not.toThrowError();
});
it('should throw for simple aggregation with invalid fields', () => {
expect(() => {
validateRuleAggregationFields({
name1: {
terms: {
field: 'alert.attributes.apiKey',
},
},
name2: {
terms: {
field: 'foo.attributes.bar1',
},
},
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid aggregation term: alert.attributes.apiKey"`);
expect(() => {
validateRuleAggregationFields({
name1: {
terms: {
field: 'alert.attributes.bar',
},
},
name2: {
terms: {
field: 'alert.attributes.consumer',
},
},
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid aggregation term: alert.attributes.bar"`);
});
it('should throw for nested aggregations with invalid fields', () => {
expect(() => {
validateRuleAggregationFields({
name1: {
terms: {
field: 'alert.attributes.apiKey',
},
aggs: {
nestedAggs: {
terms: {
field: 'foo.attributes.bar1',
},
aggs: {
anotherNestedAgg: {
terms: {
field: 'foo.attributes.bar2',
},
},
},
},
},
},
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid aggregation term: alert.attributes.apiKey"`);
expect(() => {
validateRuleAggregationFields({
name1: {
terms: {
field: 'alert.attributes.snoozeSchedule',
},
aggs: {
nestedAggs: {
terms: {
field: 'alert.attributes.enabled',
},
aggs: {
anotherNestedAgg: {
terms: {
field: 'alert.attributes.consumer',
},
},
},
},
},
},
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid aggregation term: alert.attributes.consumer"`);
});
it('should throw for both aggs and aggregations at the same nesting level with invalid fields', () => {
expect(() => {
validateRuleAggregationFields({
name1: {
terms: {
field: 'alert.attributes.snoozeSchedule',
},
aggs: {
nestedAggs1: {
terms: {
field: 'alert.attributes.enabled',
},
},
},
aggregations: {
nestedAggs2: {
terms: {
field: 'alert.attributes.consumer',
},
},
},
},
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid aggregation term: alert.attributes.consumer"`);
expect(() => {
validateRuleAggregationFields({
name1: {
terms: {
field: 'alert.attributes.snoozeSchedule',
},
aggs: {
nestedAggs1: {
terms: {
field: 'alert.attributes.consumer',
},
},
},
aggregations: {
nestedAggs2: {
terms: {
field: 'alert.attributes.enabled',
},
},
},
},
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid aggregation term: alert.attributes.consumer"`);
});
it('should throw for nested aggregations with invalid root level aggs types', () => {
expect(() => {
validateRuleAggregationFields({
name1: {
cardinality: {
field: 'alert.attributes.muteAll',
},
},
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid aggregation type: cardinality"`);
expect(() => {
validateRuleAggregationFields({
name1: {
aggs: {
nestedAggs: {
terms: {
field: 'alert.attributes.executionStatus.status',
},
avg: {
field: 'alert.attributes.executionStatus.status',
},
},
},
max: {
field: 'alert.attributes.executionStatus.status',
},
cardinality: {
field: 'alert.attributes.executionStatus.status',
},
},
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid aggregation type: max"`);
});
it('should throw for invalid multi_terms aggregations', () => {
expect(() => {
validateRuleAggregationFields({
name1: {
multi_terms: {
terms: [{ field: 'foo.attributes.bar' }, { field: 'alert.attributes.apiKey' }],
},
aggs: {
nestedAggs: {
multi_terms: {
terms: [{ field: 'foo.attributes.bar2' }, { field: 'foo.attributes.bar3' }],
},
},
},
},
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid aggregation type: multi_terms"`);
expect(() => {
validateRuleAggregationFields({
name1: {
multi_terms: {
terms: [{ field: 'foo.attributes.bar' }, { field: 'foo.attributes.bar1' }],
},
aggs: {
nestedAggs: {
multi_terms: {
terms: [{ field: 'alert.attributes.consumer' }, { field: 'foo.attributes.bar3' }],
},
},
},
},
});
}).toThrowErrorMatchingInlineSnapshot(`"Invalid aggregation type: multi_terms"`);
});
});

View file

@ -0,0 +1,73 @@
/*
* 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 type { AggregationsAggregationContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
const ALLOW_FIELDS = [
'alert.attributes.executionStatus.status',
'alert.attributes.lastRun.outcome',
'alert.attributes.muteAll',
'alert.attributes.tags',
'alert.attributes.snoozeSchedule',
'alert.attributes.snoozeSchedule.duration',
'alert.attributes.alertTypeId',
'alert.attributes.enabled',
'alert.attributes.params.*',
];
const ALLOW_AGG_TYPES = ['terms', 'composite', 'nested', 'filter'];
const AGG_TYPES_TO_VERIFY = ['field', 'path'];
const AGG_KEYS = ['aggs', 'aggregations'];
export const validateRuleAggregationFields = (
aggs: Record<string, AggregationsAggregationContainer>
) => {
Object.values(aggs).forEach((aggContainer) => {
// validate root level aggregation types (non aggs/aggregations)
validateTypes(aggContainer);
// Recursively go through aggs to validate terms
if (aggContainer.aggs) {
validateRuleAggregationFields(aggContainer.aggs);
}
if (aggContainer.aggregations) {
validateRuleAggregationFields(aggContainer.aggregations);
}
});
};
const validateTypes = (container: AggregationsAggregationContainer) => {
Object.entries(container).forEach(([aggType, aggContainer]) => {
// Do not try to validate aggs/aggregations, as the above function is already doing that
if (AGG_KEYS.includes(aggType)) {
return;
}
if (!ALLOW_AGG_TYPES.includes(aggType)) {
throw Boom.badRequest(`Invalid aggregation type: ${aggType}`);
}
validateFields(aggContainer);
});
};
const validateFields = (container: AggregationsAggregationContainer) => {
Object.entries(container).forEach(([aggType, aggContainer]) => {
// Found field, check field against blocklist
if (AGG_TYPES_TO_VERIFY.includes(aggType) && !ALLOW_FIELDS.includes(aggContainer)) {
throw Boom.badRequest(`Invalid aggregation term: ${aggContainer}`);
}
// Did not find anything, keep recursing if possible
if (typeof aggContainer === 'object' && aggContainer !== null && !Array.isArray(aggContainer)) {
validateFields(aggContainer);
}
});
};

View file

@ -6,90 +6,34 @@
*/
import { KueryNode, nodeBuilder } from '@kbn/es-query';
import { RawRule, RuleExecutionStatusValues, RuleLastRunOutcomeValues } from '../../types';
import type { AggregationsAggregationContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { AlertingAuthorizationEntity } from '../../authorization';
import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events';
import { buildKueryNodeFilter } from '../common';
import { alertingAuthorizationFilterOpts } from '../common/constants';
import { RulesClientContext } from '../types';
import { RawRule, AggregateOptions } from '../../types';
import { validateRuleAggregationFields } from '../lib/validate_rule_aggregation_fields';
export interface AggregateOptions extends IndexType {
search?: string;
defaultSearchOperator?: 'AND' | 'OR';
searchFields?: string[];
hasReference?: {
type: string;
id: string;
};
filter?: string | KueryNode;
maxTags?: number;
export interface AggregateParams<AggregationResult> {
options?: AggregateOptions;
aggs: Record<keyof AggregationResult, AggregationsAggregationContainer>;
}
interface IndexType {
[key: string]: unknown;
}
export interface AggregateResult {
alertExecutionStatus: { [status: string]: number };
ruleLastRunOutcome: { [status: string]: number };
ruleEnabledStatus?: { enabled: number; disabled: number };
ruleMutedStatus?: { muted: number; unmuted: number };
ruleSnoozedStatus?: { snoozed: number };
ruleTags?: string[];
}
export interface RuleAggregation {
status: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
outcome: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
muted: {
buckets: Array<{
key: number;
key_as_string: string;
doc_count: number;
}>;
};
enabled: {
buckets: Array<{
key: number;
key_as_string: string;
doc_count: number;
}>;
};
snoozed: {
count: {
doc_count: number;
};
};
tags: {
buckets: Array<{
key: string;
doc_count: number;
}>;
};
}
export async function aggregate(
export async function aggregate<T = Record<string, unknown>>(
context: RulesClientContext,
{
options: { fields, filter, maxTags = 50, ...options } = {},
}: { options?: AggregateOptions } = {}
): Promise<AggregateResult> {
params: AggregateParams<T>
): Promise<T> {
const { options = {}, aggs } = params;
const { filter, page = 1, perPage = 0, ...restOptions } = options;
let authorizationTuple;
try {
authorizationTuple = await context.authorization.getFindAuthorizationFilter(
AlertingAuthorizationEntity.Rule,
alertingAuthorizationFilterOpts
);
validateRuleAggregationFields(aggs);
} catch (error) {
context.auditLogger?.log(
ruleAuditEvent({
@ -103,124 +47,18 @@ export async function aggregate(
const { filter: authorizationFilter } = authorizationTuple;
const filterKueryNode = buildKueryNodeFilter(filter);
const resp = await context.unsecuredSavedObjectsClient.find<RawRule, RuleAggregation>({
...options,
const result = await context.unsecuredSavedObjectsClient.find<RawRule, T>({
...restOptions,
filter:
authorizationFilter && filterKueryNode
? nodeBuilder.and([filterKueryNode, authorizationFilter as KueryNode])
: authorizationFilter,
page: 1,
perPage: 0,
page,
perPage,
type: 'alert',
aggs: {
status: {
terms: { field: 'alert.attributes.executionStatus.status' },
},
outcome: {
terms: { field: 'alert.attributes.lastRun.outcome' },
},
enabled: {
terms: { field: 'alert.attributes.enabled' },
},
muted: {
terms: { field: 'alert.attributes.muteAll' },
},
tags: {
terms: { field: 'alert.attributes.tags', order: { _key: 'asc' }, size: maxTags },
},
snoozed: {
nested: {
path: 'alert.attributes.snoozeSchedule',
},
aggs: {
count: {
filter: {
exists: {
field: 'alert.attributes.snoozeSchedule.duration',
},
},
},
},
},
},
aggs,
});
if (!resp.aggregations) {
// Return a placeholder with all zeroes
const placeholder: AggregateResult = {
alertExecutionStatus: {},
ruleLastRunOutcome: {},
ruleEnabledStatus: {
enabled: 0,
disabled: 0,
},
ruleMutedStatus: {
muted: 0,
unmuted: 0,
},
ruleSnoozedStatus: { snoozed: 0 },
};
for (const key of RuleExecutionStatusValues) {
placeholder.alertExecutionStatus[key] = 0;
}
return placeholder;
}
const alertExecutionStatus = resp.aggregations.status.buckets.map(
({ key, doc_count: docCount }) => ({
[key]: docCount,
})
);
const ruleLastRunOutcome = resp.aggregations.outcome.buckets.map(
({ key, doc_count: docCount }) => ({
[key]: docCount,
})
);
const ret: AggregateResult = {
alertExecutionStatus: alertExecutionStatus.reduce(
(acc, curr: { [status: string]: number }) => Object.assign(acc, curr),
{}
),
ruleLastRunOutcome: ruleLastRunOutcome.reduce(
(acc, curr: { [status: string]: number }) => Object.assign(acc, curr),
{}
),
};
// Fill missing keys with zeroes
for (const key of RuleExecutionStatusValues) {
if (!ret.alertExecutionStatus.hasOwnProperty(key)) {
ret.alertExecutionStatus[key] = 0;
}
}
for (const key of RuleLastRunOutcomeValues) {
if (!ret.ruleLastRunOutcome.hasOwnProperty(key)) {
ret.ruleLastRunOutcome[key] = 0;
}
}
const enabledBuckets = resp.aggregations.enabled.buckets;
ret.ruleEnabledStatus = {
enabled: enabledBuckets.find((bucket) => bucket.key === 1)?.doc_count ?? 0,
disabled: enabledBuckets.find((bucket) => bucket.key === 0)?.doc_count ?? 0,
};
const mutedBuckets = resp.aggregations.muted.buckets;
ret.ruleMutedStatus = {
muted: mutedBuckets.find((bucket) => bucket.key === 1)?.doc_count ?? 0,
unmuted: mutedBuckets.find((bucket) => bucket.key === 0)?.doc_count ?? 0,
};
ret.ruleSnoozedStatus = {
snoozed: resp.aggregations.snoozed?.count?.doc_count ?? 0,
};
const tagsBuckets = resp.aggregations.tags?.buckets || [];
ret.ruleTags = tagsBuckets.map((bucket) => bucket.key);
return ret;
// params.
return result.aggregations!;
}

View file

@ -33,7 +33,7 @@ import {
GetRuleExecutionKPIParams,
} from './methods/get_execution_kpi';
import { find, FindParams } from './methods/find';
import { aggregate, AggregateOptions } from './methods/aggregate';
import { aggregate, AggregateParams } from './methods/aggregate';
import { deleteRule } from './methods/delete';
import { update, UpdateOptions } from './methods/update';
import { bulkDeleteRules } from './methods/bulk_delete';
@ -77,7 +77,8 @@ export class RulesClient {
};
}
public aggregate = (params?: { options?: AggregateOptions }) => aggregate(this.context, params);
public aggregate = <T = Record<string, unknown>>(params: AggregateParams<T>): Promise<T> =>
aggregate<T>(this.context, params);
public clone = <Params extends RuleTypeParams = never>(...args: CloneArguments) =>
clone<Params>(this.context, ...args);
public create = <Params extends RuleTypeParams = never>(params: CreateOptions<Params>) =>

View file

@ -16,7 +16,11 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
import { getBeforeSetup, setGlobalDate } from './lib';
import { RecoveredActionGroup } from '../../../common';
import {
RecoveredActionGroup,
getDefaultRuleAggregation,
DefaultRuleAggregationResult,
} from '../../../common';
import { RegistryRuleType } from '../../rule_type_registry';
import { fromKueryExpression, nodeTypes } from '@kbn/es-query';
@ -157,38 +161,107 @@ describe('aggregate()', () => {
test('calls saved objects client with given params to perform aggregation', async () => {
const rulesClient = new RulesClient(rulesClientParams);
const result = await rulesClient.aggregate({ options: {} });
const result = await rulesClient.aggregate<DefaultRuleAggregationResult>({
options: {},
aggs: getDefaultRuleAggregation(),
});
expect(result).toMatchInlineSnapshot(`
Object {
"alertExecutionStatus": Object {
"active": 8,
"error": 6,
"ok": 10,
"pending": 4,
"unknown": 2,
"warning": 1,
"enabled": Object {
"buckets": Array [
Object {
"doc_count": 2,
"key": 0,
"key_as_string": "0",
},
Object {
"doc_count": 28,
"key": 1,
"key_as_string": "1",
},
],
},
"ruleEnabledStatus": Object {
"disabled": 2,
"enabled": 28,
"muted": Object {
"buckets": Array [
Object {
"doc_count": 27,
"key": 0,
"key_as_string": "0",
},
Object {
"doc_count": 3,
"key": 1,
"key_as_string": "1",
},
],
},
"ruleLastRunOutcome": Object {
"failed": 4,
"succeeded": 2,
"warning": 6,
"outcome": Object {
"buckets": Array [
Object {
"doc_count": 2,
"key": "succeeded",
},
Object {
"doc_count": 4,
"key": "failed",
},
Object {
"doc_count": 6,
"key": "warning",
},
],
},
"ruleMutedStatus": Object {
"muted": 3,
"unmuted": 27,
"snoozed": Object {
"count": Object {
"doc_count": 0,
},
"doc_count": 0,
},
"ruleSnoozedStatus": Object {
"snoozed": 0,
"status": Object {
"buckets": Array [
Object {
"doc_count": 8,
"key": "active",
},
Object {
"doc_count": 6,
"key": "error",
},
Object {
"doc_count": 10,
"key": "ok",
},
Object {
"doc_count": 4,
"key": "pending",
},
Object {
"doc_count": 2,
"key": "unknown",
},
Object {
"doc_count": 1,
"key": "warning",
},
],
},
"tags": Object {
"buckets": Array [
Object {
"doc_count": 10,
"key": "a",
},
Object {
"doc_count": 20,
"key": "b",
},
Object {
"doc_count": 30,
"key": "c",
},
],
},
"ruleTags": Array [
"a",
"b",
"c",
],
}
`);
expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1);
@ -244,7 +317,10 @@ describe('aggregate()', () => {
});
const rulesClient = new RulesClient(rulesClientParams);
await rulesClient.aggregate({ options: { filter: 'foo: someTerm' } });
await rulesClient.aggregate({
options: { filter: 'foo: someTerm' },
aggs: getDefaultRuleAggregation(),
});
expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1);
expect(unsecuredSavedObjectsClient.find.mock.calls[0]).toEqual([
@ -296,7 +372,7 @@ describe('aggregate()', () => {
const rulesClient = new RulesClient({ ...rulesClientParams, auditLogger });
authorization.getFindAuthorizationFilter.mockRejectedValue(new Error('Unauthorized'));
await expect(rulesClient.aggregate()).rejects.toThrow();
await expect(rulesClient.aggregate({ aggs: getDefaultRuleAggregation() })).rejects.toThrow();
expect(auditLogger.log).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
@ -315,7 +391,7 @@ describe('aggregate()', () => {
test('sets to default (50) if it is not provided', async () => {
const rulesClient = new RulesClient(rulesClientParams);
await rulesClient.aggregate();
await rulesClient.aggregate({ aggs: getDefaultRuleAggregation() });
expect(unsecuredSavedObjectsClient.find.mock.calls[0]).toMatchObject([
{
@ -331,7 +407,9 @@ describe('aggregate()', () => {
test('sets to the provided value', async () => {
const rulesClient = new RulesClient(rulesClientParams);
await rulesClient.aggregate({ options: { maxTags: 1000 } });
await rulesClient.aggregate({
aggs: getDefaultRuleAggregation({ maxTags: 1000 }),
});
expect(unsecuredSavedObjectsClient.find.mock.calls[0]).toMatchObject([
{

View file

@ -37,7 +37,6 @@ export type {
export type { CreateOptions } from './methods/create';
export type { FindOptions, FindResult } from './methods/find';
export type { UpdateOptions } from './methods/update';
export type { AggregateOptions, AggregateResult } from './methods/aggregate';
export type { GetAlertSummaryParams } from './methods/get_alert_summary';
export type {
GetExecutionLogByIdParams,

View file

@ -14,6 +14,12 @@ import {
} from '../../../../routes/__mocks__/request_responses';
import { requestContextMock, serverMock } from '../../../../routes/__mocks__';
const emptyTagAggregationResult = {
tags: {
buckets: [],
},
};
describe('Rule management filters route', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
@ -30,6 +36,7 @@ describe('Rule management filters route', () => {
describe('status codes', () => {
test('returns 200', async () => {
clients.rulesClient.aggregate.mockResolvedValue(emptyTagAggregationResult);
const response = await server.inject(
getRuleManagementFiltersRequest(),
requestContextMock.convertContext(context)
@ -41,6 +48,7 @@ describe('Rule management filters route', () => {
clients.rulesClient.find.mockImplementation(async () => {
throw new Error('Test error');
});
clients.rulesClient.aggregate.mockResolvedValue(emptyTagAggregationResult);
const response = await server.inject(
getRuleManagementFiltersRequest(),
requestContextMock.convertContext(context)
@ -57,9 +65,34 @@ describe('Rule management filters route', () => {
test('1 rule installed, 1 custom rule and 3 tags', async () => {
clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit());
clients.rulesClient.aggregate.mockResolvedValue({
alertExecutionStatus: {},
ruleLastRunOutcome: {},
ruleTags: ['a', 'b', 'c'],
status: {
buckets: [],
},
outcome: {
buckets: [],
},
tags: {
buckets: [
{
key: {
tags: 'a',
},
doc_count: 1,
},
{
key: {
tags: 'b',
},
doc_count: 1,
},
{
key: {
tags: 'c',
},
doc_count: 1,
},
],
},
});
const request = getRuleManagementFiltersRequest();
const response = await server.inject(request, requestContextMock.convertContext(context));

View file

@ -17,9 +17,34 @@ describe('read_tags', () => {
test('it should return tags from the aggregation', async () => {
const rulesClient = rulesClientMock.create();
rulesClient.aggregate.mockResolvedValue({
alertExecutionStatus: {},
ruleLastRunOutcome: {},
ruleTags: ['tag 1', 'tag 2', 'tag 3', 'tag 4'],
tags: {
buckets: [
{
key: {
tags: 'tag 1',
},
doc_count: 1,
},
{
key: {
tags: 'tag 2',
},
doc_count: 1,
},
{
key: {
tags: 'tag 3',
},
doc_count: 1,
},
{
key: {
tags: 'tag 4',
},
doc_count: 1,
},
],
},
});
const tags = await readTags({ rulesClient });

View file

@ -6,6 +6,11 @@
*/
import type { RulesClient } from '@kbn/alerting-plugin/server';
import type { RuleTagsAggregationResult } from '@kbn/alerting-plugin/common';
import {
getRuleTagsAggregation,
formatRuleTagsAggregationResult,
} from '@kbn/alerting-plugin/common';
import { enrichFilterWithRuleTypeMapping } from '../../../logic/search/enrich_filter_with_rule_type_mappings';
// This is a contrived max limit on the number of tags. In fact it can exceed this number and will be truncated to the hardcoded number.
@ -17,13 +22,14 @@ export const readTags = async ({
rulesClient: RulesClient;
perPage?: number;
}): Promise<string[]> => {
const res = await rulesClient.aggregate({
const res = await rulesClient.aggregate<RuleTagsAggregationResult>({
options: {
fields: ['tags'],
filter: enrichFilterWithRuleTypeMapping(undefined),
maxTags: EXPECTED_MAX_TAGS,
},
aggs: getRuleTagsAggregation({
maxTags: EXPECTED_MAX_TAGS,
}),
});
return res.ruleTags ?? [];
return formatRuleTagsAggregationResult(res).ruleTags;
};

View file

@ -6,19 +6,21 @@
*/
import { HttpSetup } from '@kbn/core/public';
import { AsApiContract } from '@kbn/actions-plugin/common';
import { RuleAggregations } from '../../../types';
import {
RuleAggregationFormattedResult,
RuleTagsAggregationFormattedResult,
} from '@kbn/alerting-plugin/common';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
import { mapFiltersToKql } from './map_filters_to_kql';
import {
LoadRuleAggregationsProps,
rewriteBodyRes,
rewriteTagsBodyRes,
RuleTagsAggregations,
} from './aggregate_helpers';
import { LoadRuleAggregationsProps, rewriteBodyRes, rewriteTagsBodyRes } from './aggregate_helpers';
// TODO: https://github.com/elastic/kibana/issues/131682
export async function loadRuleTags({ http }: { http: HttpSetup }): Promise<RuleTagsAggregations> {
const res = await http.get<AsApiContract<RuleAggregations>>(
export async function loadRuleTags({
http,
}: {
http: HttpSetup;
}): Promise<RuleTagsAggregationFormattedResult> {
const res = await http.get<AsApiContract<RuleTagsAggregationFormattedResult>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/_aggregate`
);
return rewriteTagsBodyRes(res);
@ -32,7 +34,7 @@ export async function loadRuleAggregations({
ruleExecutionStatusesFilter,
ruleStatusesFilter,
tagsFilter,
}: LoadRuleAggregationsProps): Promise<RuleAggregations> {
}: LoadRuleAggregationsProps): Promise<RuleAggregationFormattedResult> {
const filters = mapFiltersToKql({
typesFilter,
actionTypesFilter,
@ -40,7 +42,7 @@ export async function loadRuleAggregations({
ruleStatusesFilter,
tagsFilter,
});
const res = await http.post<AsApiContract<RuleAggregations>>(
const res = await http.post<AsApiContract<RuleAggregationFormattedResult>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/_aggregate`,
{
body: JSON.stringify({

View file

@ -7,13 +7,13 @@
import { HttpSetup } from '@kbn/core/public';
import { RewriteRequestCase } from '@kbn/actions-plugin/common';
import { RuleAggregations, RuleStatus } from '../../../types';
import {
RuleAggregationFormattedResult,
RuleTagsAggregationFormattedResult,
} from '@kbn/alerting-plugin/common';
import { RuleStatus } from '../../../types';
export interface RuleTagsAggregations {
ruleTags: string[];
}
export const rewriteBodyRes: RewriteRequestCase<RuleAggregations> = ({
export const rewriteBodyRes: RewriteRequestCase<RuleAggregationFormattedResult> = ({
rule_execution_status: ruleExecutionStatus,
rule_last_run_outcome: ruleLastRunOutcome,
rule_enabled_status: ruleEnabledStatus,
@ -31,7 +31,7 @@ export const rewriteBodyRes: RewriteRequestCase<RuleAggregations> = ({
ruleTags,
});
export const rewriteTagsBodyRes: RewriteRequestCase<RuleTagsAggregations> = ({
export const rewriteTagsBodyRes: RewriteRequestCase<RuleTagsAggregationFormattedResult> = ({
rule_tags: ruleTags,
}: any) => ({
ruleTags,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { AsApiContract } from '@kbn/actions-plugin/common';
import { RuleAggregations } from '../../../types';
import { RuleAggregationFormattedResult } from '@kbn/alerting-plugin/common';
import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants';
import { LoadRuleAggregationsProps, rewriteBodyRes } from './aggregate_helpers';
import { mapFiltersToKueryNode } from './map_filters_to_kuery_node';
@ -18,7 +18,7 @@ export async function loadRuleAggregationsWithKueryFilter({
ruleExecutionStatusesFilter,
ruleStatusesFilter,
tagsFilter,
}: LoadRuleAggregationsProps): Promise<RuleAggregations> {
}: LoadRuleAggregationsProps): Promise<RuleAggregationFormattedResult> {
const filtersKueryNode = mapFiltersToKueryNode({
typesFilter,
actionTypesFilter,
@ -28,7 +28,7 @@ export async function loadRuleAggregationsWithKueryFilter({
searchText,
});
const res = await http.post<AsApiContract<RuleAggregations>>(
const res = await http.post<AsApiContract<RuleAggregationFormattedResult>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/_aggregate`,
{
body: JSON.stringify({

View file

@ -41,7 +41,6 @@ import {
SanitizedRule as AlertingSanitizedRule,
ResolvedSanitizedRule,
RuleAction,
RuleAggregations as AlertingRuleAggregations,
RuleTaskState,
AlertSummary as RuleSummary,
ExecutionDuration,
@ -103,14 +102,10 @@ type Rule<Params extends RuleTypeParams = RuleTypeParams> = SanitizedRule<Params
type ResolvedRule = Omit<ResolvedSanitizedRule<RuleTypeParams>, 'alertTypeId'> & {
ruleTypeId: ResolvedSanitizedRule['alertTypeId'];
};
type RuleAggregations = Omit<AlertingRuleAggregations, 'alertExecutionStatus'> & {
ruleExecutionStatus: AlertingRuleAggregations['alertExecutionStatus'];
};
export type {
Rule,
RuleAction,
RuleAggregations,
RuleTaskState,
RuleSummary,
ExecutionDuration,

View file

@ -0,0 +1,109 @@
/*
* 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 { Spaces } from '../../../scenarios';
import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
const tags = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'];
// eslint-disable-next-line import/no-default-export
export default function createAggregateTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const createRule = async (overrides = {}) => {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(getTestRuleData(overrides))
.expect(200);
return createdRule.id;
};
describe('getRuleTags', () => {
const objectRemover = new ObjectRemover(supertest);
afterEach(() => objectRemover.removeAll());
it('should get rule tags when there are no rules', async () => {
const response = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags`
);
expect(response.status).to.eql(200);
expect(response.body.rule_tags.filter((tag: string) => tag !== 'foo')).to.eql([]);
});
it('should get rule tags from all rules', async () => {
await Promise.all(
tags.map(async (tag) => {
const ruleId = await createRule({ tags: [tag] });
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
})
);
const ruleId = await createRule({ tags: ['a', 'b', 'c'] });
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
const response = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags`
);
expect(response.status).to.eql(200);
expect(response.body.rule_tags.filter((tag: string) => tag !== 'foo').sort()).to.eql(
tags.sort()
);
});
it('should paginate rule tags', async () => {
await Promise.all(
tags.map(async (tag) => {
const ruleId = await createRule({ tags: [tag] });
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
})
);
const ruleId = await createRule({ tags: ['foo'] });
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
const response = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags?max_tags=5`
);
expect(response.status).to.eql(200);
expect(response.body.rule_tags).to.eql(tags.sort().slice(0, 5));
const paginatedResponse = await supertest.get(
`${getUrlPrefix(
Spaces.space1.id
)}/internal/alerting/rules/_tags?max_tags=5&after=${JSON.stringify({
tags: 'e',
})}`
);
expect(paginatedResponse.status).to.eql(200);
expect(paginatedResponse.body.rule_tags).to.eql(['f', 'foo', 'g', 'h', 'i']);
});
it('should search rule tags', async () => {
await Promise.all(
tags.map(async (tag) => {
const ruleId = await createRule({ tags: [tag] });
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
})
);
const response = await supertest.get(
`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_tags?search=a`
);
expect(response.body.rule_tags.filter((tag: string) => tag !== 'foo')).to.eql(['a']);
});
});
}

View file

@ -26,6 +26,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./get_alert_summary'));
loadTestFile(require.resolve('./get_execution_log'));
loadTestFile(require.resolve('./get_action_error_log'));
loadTestFile(require.resolve('./get_rule_tags'));
loadTestFile(require.resolve('./rule_types'));
loadTestFile(require.resolve('./event_log'));
});