[RAM][Maintenance Window] MW scoped query schema and API changes (#171597)

## Summary

Partially Resolves: https://github.com/elastic/kibana/issues/164255

This pull request is part 1/3 to add scoped queries to maintenance
windows. More specifically, this PR adds the new `scoped_query` field to
the `maintenanceWindow` type and schema. Also adds the `scoped_query`
field to `create/update` maintenance window APIs.

This PR only contains the schema and API component. All changes should
be backwards compatible since the `scoped_query` field is optional. So
this PR can be merged without any dependencies.

The 2 PRs that comes after will be:
- Frontend changes
- Task runner changes

### 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: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jiawei Wu 2023-11-22 12:51:52 -08:00 committed by GitHub
parent d5754ad46f
commit 92bc2a0d7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 711 additions and 90 deletions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { LicenseType } from '@kbn/licensing-plugin/server';
import type { LicenseType } from '@kbn/licensing-plugin/server';
export const PLUGIN = {
ID: 'alerting',

View file

@ -14,6 +14,13 @@ export enum MaintenanceWindowStatus {
Archived = 'archived',
}
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;
export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore];
export interface MaintenanceWindowModificationMetadata {
createdBy: string | null;
updatedBy: string | null;
@ -26,6 +33,23 @@ export interface DateRange {
lte: string;
}
export interface ScopeQueryFilter {
query?: Record<string, unknown>;
meta: Record<string, unknown>;
$state?: {
store: FilterStateStore;
};
}
export interface ScopedQueryAttributes {
kql: string;
filters: ScopeQueryFilter[];
dsl?: string;
}
/**
* @deprecated Use the data/maintenance_window types instead
*/
export interface MaintenanceWindowSOProperties {
title: string;
enabled: boolean;
@ -34,11 +58,18 @@ export interface MaintenanceWindowSOProperties {
events: DateRange[];
rRule: RRuleParams;
categoryIds?: string[] | null;
scopedQuery?: ScopedQueryAttributes | null;
}
/**
* @deprecated Use the data/maintenance_window types instead
*/
export type MaintenanceWindowSOAttributes = MaintenanceWindowSOProperties &
MaintenanceWindowModificationMetadata;
/**
* @deprecated Use the application/maintenance_window types instead
*/
export type MaintenanceWindow = MaintenanceWindowSOAttributes & {
status: MaintenanceWindowStatus;
eventStartTime: string | null;

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { filterStateStore } from './v1';
export type { FilterStateStore } from './v1';

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;
export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore];

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
export { filterStateStore } from './constants/latest';
export type { FilterStateStore } from './constants/latest';
export { alertsFilterQuerySchema } from './schemas/latest';
export { filterStateStore as filterStateStoreV1 } from './constants/v1';
export type { FilterStateStore as FilterStateStoreV1 } from './constants/v1';
export { alertsFilterQuerySchema as alertsFilterQuerySchemaV1 } from './schemas/v1';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { alertsFilterQuerySchema } from './v1';

View file

@ -0,0 +1,28 @@
/*
* 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 { filterStateStore } from '..';
export const alertsFilterQuerySchema = schema.object({
kql: schema.string(),
filters: schema.arrayOf(
schema.object({
query: schema.maybe(schema.recordOf(schema.string(), schema.any())),
meta: schema.recordOf(schema.string(), schema.any()),
$state: schema.maybe(
schema.object({
store: schema.oneOf([
schema.literal(filterStateStore.APP_STATE),
schema.literal(filterStateStore.GLOBAL_STATE),
]),
})
),
})
),
dsl: schema.maybe(schema.string()),
});

View file

@ -8,10 +8,12 @@
import { schema } from '@kbn/config-schema';
import { maintenanceWindowCategoryIdsSchemaV1 } from '../../../shared';
import { rRuleRequestSchemaV1 } from '../../../../r_rule';
import { alertsFilterQuerySchemaV1 } from '../../../../alerts_filter_query';
export const createBodySchema = schema.object({
title: schema.string(),
duration: schema.number(),
r_rule: rRuleRequestSchemaV1,
category_ids: maintenanceWindowCategoryIdsSchemaV1,
scoped_query: schema.maybe(schema.nullable(alertsFilterQuerySchemaV1)),
});

View file

@ -8,6 +8,7 @@
import { schema } from '@kbn/config-schema';
import { maintenanceWindowCategoryIdsSchemaV1 } from '../../../shared';
import { rRuleRequestSchemaV1 } from '../../../../r_rule';
import { alertsFilterQuerySchemaV1 } from '../../../../alerts_filter_query';
export const updateParamsSchema = schema.object({
id: schema.string(),
@ -19,4 +20,5 @@ export const updateBodySchema = schema.object({
duration: schema.maybe(schema.number()),
r_rule: schema.maybe(rRuleRequestSchemaV1),
category_ids: maintenanceWindowCategoryIdsSchemaV1,
scoped_query: schema.maybe(schema.nullable(alertsFilterQuerySchemaV1)),
});

View file

@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
import { maintenanceWindowStatusV1 } from '..';
import { maintenanceWindowCategoryIdsSchemaV1 } from '../../shared';
import { rRuleResponseSchemaV1 } from '../../../r_rule';
import { alertsFilterQuerySchemaV1 } from '../../../alerts_filter_query';
export const maintenanceWindowEventSchema = schema.object({
gte: schema.string(),
@ -36,4 +37,5 @@ export const maintenanceWindowResponseSchema = schema.object({
schema.literal(maintenanceWindowStatusV1.ARCHIVED),
]),
category_ids: maintenanceWindowCategoryIdsSchemaV1,
scoped_query: schema.maybe(schema.nullable(alertsFilterQuerySchemaV1)),
});

View file

@ -8,7 +8,7 @@
import { schema } from '@kbn/config-schema';
import { validateDurationV1, validateHoursV1, validateTimezoneV1 } from '../../../validation';
import { notifyWhenSchemaV1 } from '../../../response';
import { filterStateStore } from '../../../common/constants/v1';
import { alertsFilterQuerySchemaV1 } from '../../../../alerts_filter_query';
export const actionFrequencySchema = schema.object({
summary: schema.boolean(),
@ -17,26 +17,7 @@ export const actionFrequencySchema = schema.object({
});
export const actionAlertsFilterSchema = schema.object({
query: schema.maybe(
schema.object({
kql: schema.string(),
filters: schema.arrayOf(
schema.object({
query: schema.maybe(schema.recordOf(schema.string(), schema.any())),
meta: schema.recordOf(schema.string(), schema.any()),
$state: schema.maybe(
schema.object({
store: schema.oneOf([
schema.literal(filterStateStore.APP_STATE),
schema.literal(filterStateStore.GLOBAL_STATE),
]),
})
),
})
),
dsl: schema.maybe(schema.string()),
})
),
query: schema.maybe(alertsFilterQuerySchemaV1),
timeframe: schema.maybe(
schema.object({
days: schema.arrayOf(

View file

@ -7,13 +7,13 @@
import { schema } from '@kbn/config-schema';
import { rRuleResponseSchemaV1 } from '../../../r_rule';
import { alertsFilterQuerySchemaV1 } from '../../../alerts_filter_query';
import {
ruleNotifyWhen as ruleNotifyWhenV1,
ruleExecutionStatusValues as ruleExecutionStatusValuesV1,
ruleExecutionStatusErrorReason as ruleExecutionStatusErrorReasonV1,
ruleExecutionStatusWarningReason as ruleExecutionStatusWarningReasonV1,
ruleLastRunOutcomeValues as ruleLastRunOutcomeValuesV1,
filterStateStore as filterStateStoreV1,
} from '../../common/constants/v1';
import { validateNotifyWhenV1 } from '../../validation';
@ -41,25 +41,7 @@ const actionFrequencySchema = schema.object({
});
const actionAlertsFilterSchema = schema.object({
query: schema.maybe(
schema.object({
kql: schema.string(),
filters: schema.arrayOf(
schema.object({
query: schema.maybe(schema.recordOf(schema.string(), schema.any())),
meta: schema.recordOf(schema.string(), schema.any()),
$state: schema.maybe(
schema.object({
store: schema.oneOf([
schema.literal(filterStateStoreV1.APP_STATE),
schema.literal(filterStateStoreV1.GLOBAL_STATE),
]),
})
),
})
),
})
),
query: schema.maybe(alertsFilterQuerySchemaV1),
timeframe: schema.maybe(
schema.object({
days: schema.arrayOf(

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;
export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore];

View file

@ -0,0 +1,28 @@
/*
* 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 { filterStateStore } from '../constants';
export const alertsFilterQuerySchema = schema.object({
kql: schema.string(),
filters: schema.arrayOf(
schema.object({
query: schema.maybe(schema.recordOf(schema.string(), schema.any())),
meta: schema.recordOf(schema.string(), schema.any()),
$state: schema.maybe(
schema.object({
store: schema.oneOf([
schema.literal(filterStateStore.APP_STATE),
schema.literal(filterStateStore.GLOBAL_STATE),
]),
})
),
})
),
dsl: schema.maybe(schema.string()),
});

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { alertsFilterQuerySchema } from './alerts_filter_query_schemas';

View file

@ -0,0 +1,11 @@
/*
* 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 { TypeOf } from '@kbn/config-schema';
import { alertsFilterQuerySchema } from '../schemas/alerts_filter_query_schemas';
export type AlertsFilterQuery = TypeOf<typeof alertsFilterQuerySchema>;

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export type { AlertsFilterQuery } from './alerts_filter_query';

View file

@ -13,8 +13,12 @@ export const maintenanceWindowStatus = {
} as const;
export const maintenanceWindowCategoryIdTypes = {
KIBANA: 'kibana',
OBSERVABILITY: 'observability',
SECURITY_SOLUTION: 'securitySolution',
MANAGEMENT: 'management',
} as const;
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;

View file

@ -8,7 +8,8 @@
import _ from 'lodash';
import moment from 'moment-timezone';
import { RRule, Weekday } from '@kbn/rrule';
import { RRuleParams, MaintenanceWindowSOAttributes, DateRange } from '../../../../common';
import { RRuleParams, DateRange } from '../../../../common';
import { MaintenanceWindow } from '../types';
export interface GenerateMaintenanceWindowEventsParams {
rRule: RRuleParams;
@ -58,7 +59,7 @@ export const shouldRegenerateEvents = ({
rRule,
duration,
}: {
maintenanceWindow: MaintenanceWindowSOAttributes;
maintenanceWindow: MaintenanceWindow;
rRule?: RRuleParams;
duration?: number;
}): boolean => {

View file

@ -133,6 +133,124 @@ describe('MaintenanceWindowClient - create', () => {
);
});
it('should create maintenance window with scoped query', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
});
savedObjectsClient.create.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
await createMaintenanceWindow(mockContext, {
data: {
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule as CreateMaintenanceWindowParams['data']['rRule'],
categoryIds: ['observability', 'securitySolution'],
scopedQuery: {
kql: "_id: '1234'",
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
key: 'kibana.alert.action_group',
field: 'kibana.alert.action_group',
params: {
query: 'test',
},
type: 'phrase',
},
$state: {
store: 'appState',
},
query: {
match_phrase: {
'kibana.alert.action_group': 'test',
},
},
},
],
},
},
});
expect(savedObjectsClient.create).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
expect.objectContaining({
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule,
enabled: true,
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
categoryIds: ['observability', 'securitySolution'],
...updatedMetadata,
}),
{
id: expect.any(String),
}
);
expect(
(savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow).scopedQuery!.kql
).toEqual(`_id: '1234'`);
expect(
(savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow).scopedQuery!.filters[0]
).toEqual({
$state: { store: 'appState' },
meta: {
alias: null,
disabled: false,
field: 'kibana.alert.action_group',
key: 'kibana.alert.action_group',
negate: false,
params: { query: 'test' },
type: 'phrase',
},
query: { match_phrase: { 'kibana.alert.action_group': 'test' } },
});
expect(
(savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow).scopedQuery!.dsl
).toMatchInlineSnapshot(
`"{\\"bool\\":{\\"must\\":[],\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"_id\\":\\"'1234'\\"}}],\\"minimum_should_match\\":1}},{\\"match_phrase\\":{\\"kibana.alert.action_group\\":\\"test\\"}}],\\"should\\":[],\\"must_not\\":[]}}"`
);
});
it('should throw if trying to create a maintenance window with invalid scoped query', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
});
await expect(async () => {
await createMaintenanceWindow(mockContext, {
data: {
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule as CreateMaintenanceWindowParams['data']['rRule'],
categoryIds: ['observability', 'securitySolution'],
scopedQuery: {
kql: 'invalid: ',
filters: [],
},
},
});
}).rejects.toThrowErrorMatchingInlineSnapshot(`
"Error validating create maintenance scoped query - Expected \\"(\\", \\"{\\", value, whitespace but end of input found.
invalid:
---------^"
`);
});
it('should throw if trying to create a maintenance window with invalid category ids', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));

View file

@ -8,6 +8,7 @@
import moment from 'moment';
import Boom from '@hapi/boom';
import { SavedObjectsUtils } from '@kbn/core/server';
import { buildEsQuery, Filter } from '@kbn/es-query';
import { generateMaintenanceWindowEvents } from '../../lib/generate_maintenance_window_events';
import type { MaintenanceWindowClientContext } from '../../../../../common';
import type { MaintenanceWindow } from '../../types';
@ -25,7 +26,7 @@ export async function createMaintenanceWindow(
): Promise<MaintenanceWindow> {
const { data } = params;
const { savedObjectsClient, getModificationMetadata, logger } = context;
const { title, duration, rRule, categoryIds } = data;
const { title, duration, rRule, categoryIds, scopedQuery } = data;
try {
createMaintenanceWindowParamsSchema.validate(params);
@ -33,6 +34,25 @@ export async function createMaintenanceWindow(
throw Boom.badRequest(`Error validating create maintenance window data - ${error.message}`);
}
let scopedQueryWithGeneratedValue = scopedQuery;
try {
if (scopedQuery) {
const dsl = JSON.stringify(
buildEsQuery(
undefined,
[{ query: scopedQuery.kql, language: 'kuery' }],
scopedQuery.filters as Filter[]
)
);
scopedQueryWithGeneratedValue = {
...scopedQuery,
dsl,
};
}
} catch (error) {
throw Boom.badRequest(`Error validating create maintenance scoped query - ${error.message}`);
}
const id = SavedObjectsUtils.generateId();
const expirationDate = moment().utc().add(1, 'year').toISOString();
const modificationMetadata = await getModificationMetadata();
@ -43,6 +63,7 @@ export async function createMaintenanceWindow(
enabled: true,
expirationDate,
categoryIds,
scopedQuery: scopedQueryWithGeneratedValue,
rRule: rRule as MaintenanceWindow['rRule'],
duration,
events,

View file

@ -8,6 +8,7 @@
import { schema } from '@kbn/config-schema';
import { maintenanceWindowCategoryIdsSchema } from '../../../schemas';
import { rRuleRequestSchema } from '../../../../r_rule/schemas';
import { alertsFilterQuerySchema } from '../../../../alerts_filter_query/schemas';
export const createMaintenanceWindowParamsSchema = schema.object({
data: schema.object({
@ -15,5 +16,6 @@ export const createMaintenanceWindowParamsSchema = schema.object({
duration: schema.number(),
rRule: rRuleRequestSchema,
categoryIds: maintenanceWindowCategoryIdsSchema,
scopedQuery: schema.maybe(schema.nullable(alertsFilterQuerySchema)),
}),
});

View file

@ -8,6 +8,7 @@
import { schema } from '@kbn/config-schema';
import { maintenanceWindowCategoryIdsSchema } from '../../../schemas';
import { rRuleRequestSchema } from '../../../../r_rule/schemas';
import { alertsFilterQuerySchema } from '../../../../alerts_filter_query/schemas';
export const updateMaintenanceWindowParamsSchema = schema.object({
id: schema.string(),
@ -17,5 +18,6 @@ export const updateMaintenanceWindowParamsSchema = schema.object({
duration: schema.maybe(schema.number()),
rRule: schema.maybe(rRuleRequestSchema),
categoryIds: maintenanceWindowCategoryIdsSchema,
scopedQuery: schema.maybe(schema.nullable(alertsFilterQuerySchema)),
}),
});

View file

@ -207,6 +207,172 @@ describe('MaintenanceWindowClient - update', () => {
);
});
it('should update maintenance window with scoped query', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const modifiedEvents = [
{ gte: '2023-03-26T00:00:00.000Z', lte: '2023-03-26T00:12:34.000Z' },
{ gte: '2023-04-01T23:00:00.000Z', lte: '2023-04-01T23:43:21.000Z' },
];
const mockMaintenanceWindow = getMockMaintenanceWindow({
rRule: {
tzid: 'CET',
dtstart: '2023-03-26T00:00:00.000Z',
freq: Frequency.WEEKLY,
count: 5,
} as MaintenanceWindow['rRule'],
events: modifiedEvents,
expirationDate: moment(new Date(firstTimestamp)).tz('UTC').add(2, 'week').toISOString(),
});
savedObjectsClient.get.mockResolvedValue({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
savedObjectsClient.create.mockResolvedValue({
attributes: {
...mockMaintenanceWindow,
...updatedAttributes,
...updatedMetadata,
},
id: 'test-id',
} as unknown as SavedObject);
await updateMaintenanceWindow(mockContext, {
id: 'test-id',
data: {
scopedQuery: {
kql: "_id: '1234'",
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
key: 'kibana.alert.action_group',
field: 'kibana.alert.action_group',
params: {
query: 'test',
},
type: 'phrase',
},
$state: {
store: 'appState',
},
query: {
match_phrase: {
'kibana.alert.action_group': 'test',
},
},
},
],
},
},
});
expect(
(savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow).scopedQuery!.kql
).toEqual(`_id: '1234'`);
expect(
(savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow).scopedQuery!.filters[0]
).toEqual({
$state: { store: 'appState' },
meta: {
alias: null,
disabled: false,
field: 'kibana.alert.action_group',
key: 'kibana.alert.action_group',
negate: false,
params: { query: 'test' },
type: 'phrase',
},
query: { match_phrase: { 'kibana.alert.action_group': 'test' } },
});
expect(
(savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow).scopedQuery!.dsl
).toMatchInlineSnapshot(
`"{\\"bool\\":{\\"must\\":[],\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"_id\\":\\"'1234'\\"}}],\\"minimum_should_match\\":1}},{\\"match_phrase\\":{\\"kibana.alert.action_group\\":\\"test\\"}}],\\"should\\":[],\\"must_not\\":[]}}"`
);
});
it('should remove maintenance window with scoped query', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const modifiedEvents = [
{ gte: '2023-03-26T00:00:00.000Z', lte: '2023-03-26T00:12:34.000Z' },
{ gte: '2023-04-01T23:00:00.000Z', lte: '2023-04-01T23:43:21.000Z' },
];
const mockMaintenanceWindow = getMockMaintenanceWindow({
rRule: {
tzid: 'CET',
dtstart: '2023-03-26T00:00:00.000Z',
freq: Frequency.WEEKLY,
count: 5,
} as MaintenanceWindow['rRule'],
events: modifiedEvents,
expirationDate: moment(new Date(firstTimestamp)).tz('UTC').add(2, 'week').toISOString(),
});
savedObjectsClient.get.mockResolvedValue({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
savedObjectsClient.create.mockResolvedValue({
attributes: {
...mockMaintenanceWindow,
...updatedAttributes,
...updatedMetadata,
},
id: 'test-id',
} as unknown as SavedObject);
await updateMaintenanceWindow(mockContext, {
id: 'test-id',
data: {
scopedQuery: null,
},
});
expect(
(savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow).scopedQuery
).toBeNull();
});
it('should throw if updating a maintenance window with invalid scoped query', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date(firstTimestamp)).tz('UTC').subtract(1, 'year').toISOString(),
});
savedObjectsClient.get.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
await expect(async () => {
await updateMaintenanceWindow(mockContext, {
id: 'test-id',
data: {
scopedQuery: {
kql: 'invalid: ',
filters: [],
},
},
});
}).rejects.toThrowErrorMatchingInlineSnapshot(`
"Error validating update maintenance scoped query - Expected \\"(\\", \\"{\\", value, whitespace but end of input found.
invalid:
---------^"
`);
});
it('should throw if updating a maintenance window that has expired', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const mockMaintenanceWindow = getMockMaintenanceWindow({

View file

@ -7,6 +7,7 @@
import moment from 'moment';
import Boom from '@hapi/boom';
import { buildEsQuery, Filter } from '@kbn/es-query';
import type { MaintenanceWindowClientContext } from '../../../../../common';
import type { MaintenanceWindow } from '../../types';
import {
@ -45,7 +46,7 @@ async function updateWithOCC(
): Promise<MaintenanceWindow> {
const { savedObjectsClient, getModificationMetadata, logger } = context;
const { id, data } = params;
const { title, enabled, duration, rRule, categoryIds } = data;
const { title, enabled, duration, rRule, categoryIds, scopedQuery } = data;
try {
updateMaintenanceWindowParamsSchema.validate(params);
@ -53,6 +54,25 @@ async function updateWithOCC(
throw Boom.badRequest(`Error validating update maintenance window data - ${error.message}`);
}
let scopedQueryWithGeneratedValue = scopedQuery;
try {
if (scopedQuery) {
const dsl = JSON.stringify(
buildEsQuery(
undefined,
[{ query: scopedQuery.kql, language: 'kuery' }],
scopedQuery.filters as Filter[]
)
);
scopedQueryWithGeneratedValue = {
...scopedQuery,
dsl,
};
}
} catch (error) {
throw Boom.badRequest(`Error validating update maintenance scoped query - ${error.message}`);
}
try {
const {
attributes,
@ -88,6 +108,9 @@ async function updateWithOCC(
...(title ? { title } : {}),
...(rRule ? { rRule: rRule as MaintenanceWindow['rRule'] } : {}),
...(categoryIds !== undefined ? { categoryIds } : {}),
...(scopedQueryWithGeneratedValue !== undefined
? { scopedQuery: scopedQueryWithGeneratedValue }
: {}),
...(typeof duration === 'number' ? { duration } : {}),
...(typeof enabled === 'boolean' ? { enabled } : {}),
expirationDate,

View file

@ -8,6 +8,7 @@
import { schema } from '@kbn/config-schema';
import { maintenanceWindowStatus, maintenanceWindowCategoryIdTypes } from '../constants';
import { rRuleSchema } from '../../r_rule/schemas';
import { alertsFilterQuerySchema } from '../../alerts_filter_query/schemas';
export const maintenanceWindowEventSchema = schema.object({
gte: schema.string(),
@ -47,4 +48,5 @@ export const maintenanceWindowSchema = schema.object({
schema.literal(maintenanceWindowStatus.ARCHIVED),
]),
categoryIds: maintenanceWindowCategoryIdsSchema,
scopedQuery: schema.maybe(schema.nullable(alertsFilterQuerySchema)),
});

View file

@ -40,5 +40,6 @@ export const transformMaintenanceWindowAttributesToMaintenanceWindow = (
eventEndTime,
status,
...(attributes.categoryIds !== undefined ? { categoryIds: attributes.categoryIds } : {}),
...(attributes.scopedQuery !== undefined ? { scopedQuery: attributes.scopedQuery } : {}),
};
};

View file

@ -25,5 +25,8 @@ export const transformMaintenanceWindowToMaintenanceWindowAttributes = (
...(maintenanceWindow.categoryIds !== undefined
? { categoryIds: maintenanceWindow.categoryIds }
: {}),
...(maintenanceWindow.scopedQuery !== undefined
? { scopedQuery: maintenanceWindow.scopedQuery }
: {}),
};
};

View file

@ -489,7 +489,7 @@ async function updateRuleAttributesAndParamsInMemory<Params extends RuleParams>(
context,
operations,
rule: ruleDomain,
ruleActions,
ruleActions: ruleActions as RuleDomain['actions'], // TODO (http-versioning) Remove this cast once we fix injectReferencesIntoActions
ruleType,
});

View file

@ -7,31 +7,10 @@
import { schema } from '@kbn/config-schema';
import { notifyWhenSchema } from './notify_when_schema';
import { filterStateStore } from '../constants';
import { alertsFilterQuerySchema } from '../../alerts_filter_query/schemas';
export const actionParamsSchema = schema.recordOf(schema.string(), schema.maybe(schema.any()));
const actionAlertsFilterQueryFiltersSchema = schema.arrayOf(
schema.object({
query: schema.maybe(schema.recordOf(schema.string(), schema.any())),
meta: schema.recordOf(schema.string(), schema.any()),
$state: schema.maybe(
schema.object({
store: schema.oneOf([
schema.literal(filterStateStore.APP_STATE),
schema.literal(filterStateStore.GLOBAL_STATE),
]),
})
),
})
);
const actionDomainAlertsFilterQuerySchema = schema.object({
kql: schema.string(),
filters: actionAlertsFilterQueryFiltersSchema,
dsl: schema.maybe(schema.string()),
});
const actionAlertsFilterTimeFrameSchema = schema.object({
days: schema.arrayOf(
schema.oneOf([
@ -52,7 +31,7 @@ const actionAlertsFilterTimeFrameSchema = schema.object({
});
const actionDomainAlertsFilterSchema = schema.object({
query: schema.maybe(actionDomainAlertsFilterQuerySchema),
query: schema.maybe(alertsFilterQuerySchema),
timeframe: schema.maybe(actionAlertsFilterTimeFrameSchema),
});
@ -76,17 +55,8 @@ export const actionDomainSchema = schema.object({
useAlertDataAsTemplate: schema.maybe(schema.boolean()),
});
/**
* Sanitized (non-domain) action schema, returned by rules clients for other solutions
*/
const actionAlertsFilterQuerySchema = schema.object({
kql: schema.string(),
filters: actionAlertsFilterQueryFiltersSchema,
dsl: schema.maybe(schema.string()),
});
export const actionAlertsFilterSchema = schema.object({
query: schema.maybe(actionAlertsFilterQuerySchema),
query: schema.maybe(alertsFilterQuerySchema),
timeframe: schema.maybe(actionAlertsFilterTimeFrameSchema),
});

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export const filterStateStore = {
APP_STATE: 'appState',
GLOBAL_STATE: 'globalState',
} as const;
export type FilterStateStore = typeof filterStateStore[keyof typeof filterStateStore];

View file

@ -0,0 +1,22 @@
/*
* 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 { FilterStateStore } from '../constants';
export interface AlertsFilterAttributes {
query?: Record<string, unknown>;
meta: Record<string, unknown>;
$state?: {
store: FilterStateStore;
};
}
export interface AlertsFilterQueryAttributes {
kql: string;
filters: AlertsFilterAttributes[];
dsl?: string;
}

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export type { AlertsFilterQueryAttributes } from './alerts_filter_query_attributes';

View file

@ -6,7 +6,8 @@
*/
import { RRuleAttributes } from '../../r_rule/types';
import { MaintenanceWindowCategoryIdTypes } from '../constants';
import type { MaintenanceWindowCategoryIdTypes } from '../constants';
import { AlertsFilterQueryAttributes } from '../../alerts_filter_query/types';
export interface MaintenanceWindowEventAttributes {
gte: string;
@ -25,4 +26,5 @@ export interface MaintenanceWindowAttributes {
createdAt: string;
updatedAt: string;
categoryIds?: MaintenanceWindowCategoryIdTypes[] | null;
scopedQuery?: AlertsFilterQueryAttributes | null;
}

View file

@ -6,7 +6,6 @@
*/
import type { SavedObjectAttributes } from '@kbn/core/server';
import { Filter } from '@kbn/es-query';
import { IsoWeekday } from '../../../../common';
import {
ruleNotifyWhenAttributes,
@ -16,6 +15,7 @@ import {
ruleExecutionStatusWarningReasonAttributes,
} from '../constants';
import { RRuleAttributes } from '../../r_rule/types';
import { AlertsFilterQueryAttributes } from '../../alerts_filter_query/types';
export type RuleNotifyWhenAttributes =
typeof ruleNotifyWhenAttributes[keyof typeof ruleNotifyWhenAttributes];
@ -115,11 +115,7 @@ interface AlertsFilterTimeFrameAttributes {
}
interface AlertsFilterAttributes {
query?: {
kql: string;
filters: Filter[];
dsl: string;
};
query?: AlertsFilterQueryAttributes;
timeframe?: AlertsFilterTimeFrameAttributes;
}

View file

@ -13,7 +13,7 @@ import { capitalize } from 'lodash';
import { Observable, Subscription } from 'rxjs';
import { LicensingPluginStart } from '@kbn/licensing-plugin/server';
import { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types';
import { PLUGIN } from '../constants/plugin';
import { PLUGIN } from '../../common/constants/plugin';
import { getRuleTypeFeatureUsageName } from './get_rule_type_feature_usage_name';
import {
RuleType,

View file

@ -16,5 +16,6 @@ export const transformCreateBody = (
duration: createBody.duration,
rRule: createBody.r_rule,
categoryIds: createBody.category_ids,
scopedQuery: createBody.scoped_query,
};
};

View file

@ -11,12 +11,21 @@ import { UpdateMaintenanceWindowParams } from '../../../../../../application/mai
export const transformUpdateBody = (
updateBody: UpdateMaintenanceWindowRequestBodyV1
): UpdateMaintenanceWindowParams['data'] => {
const { title, enabled, duration, r_rule: rRule, category_ids: categoryIds } = updateBody;
const {
title,
enabled,
duration,
r_rule: rRule,
category_ids: categoryIds,
scoped_query: scopedQuery,
} = updateBody;
return {
...(title !== undefined ? { title } : {}),
...(enabled !== undefined ? { enabled } : {}),
...(duration !== undefined ? { duration } : {}),
...(rRule !== undefined ? { rRule } : {}),
...(categoryIds !== undefined ? { categoryIds } : {}),
...(scopedQuery !== undefined ? { scopedQuery } : {}),
};
};

View file

@ -29,5 +29,8 @@ export const transformMaintenanceWindowToResponse = (
...(maintenanceWindow.categoryIds !== undefined
? { category_ids: maintenanceWindow.categoryIds }
: {}),
...(maintenanceWindow.scopedQuery !== undefined
? { scoped_query: maintenanceWindow.scopedQuery }
: {}),
};
};

View file

@ -25,6 +25,32 @@ export default function createMaintenanceWindowTests({ getService }: FtrProvider
tzid: 'UTC',
freq: 2, // weekly
},
scoped_query: {
kql: "_id: '1234'",
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
key: 'kibana.alert.action_group',
field: 'kibana.alert.action_group',
params: {
query: 'test',
},
type: 'phrase',
},
$state: {
store: 'appState',
},
query: {
match_phrase: {
'kibana.alert.action_group': 'test',
},
},
},
],
},
};
afterEach(() => objectRemover.removeAll());
@ -69,6 +95,7 @@ export default function createMaintenanceWindowTests({ getService }: FtrProvider
expect(response.body.r_rule.dtstart).to.eql(createParams.r_rule.dtstart);
expect(response.body.events.length).to.be.greaterThan(0);
expect(response.body.status).to.eql('running');
expect(response.body.scoped_query.kql).to.eql("_id: '1234'");
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
@ -102,5 +129,19 @@ export default function createMaintenanceWindowTests({ getService }: FtrProvider
})
.expect(400);
});
it('should throw if creating maintenance window with invalid scoped query', async () => {
await supertest
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send({
...createParams,
scoped_query: {
kql: 'invalid_kql:',
filters: [],
},
})
.expect(400);
});
});
}

View file

@ -16,6 +16,33 @@ export default function updateMaintenanceWindowTests({ getService }: FtrProvider
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const scopedQuery = {
kql: "_id: '1234'",
filters: [
{
meta: {
disabled: false,
negate: false,
alias: null,
key: 'kibana.alert.action_group',
field: 'kibana.alert.action_group',
params: {
query: 'test',
},
type: 'phrase',
},
$state: {
store: 'appState',
},
query: {
match_phrase: {
'kibana.alert.action_group': 'test',
},
},
},
],
};
describe('updateMaintenanceWindow', () => {
const objectRemover = new ObjectRemover(supertest);
const createParams = {
@ -26,6 +53,7 @@ export default function updateMaintenanceWindowTests({ getService }: FtrProvider
tzid: 'UTC',
freq: 2, // weekly
},
scoped_query: scopedQuery,
};
afterEach(() => objectRemover.removeAll());
@ -82,6 +110,7 @@ export default function updateMaintenanceWindowTests({ getService }: FtrProvider
expect(response.body.r_rule.dtstart).to.eql(createParams.r_rule.dtstart);
expect(response.body.events.length).to.be.greaterThan(0);
expect(response.body.status).to.eql('running');
expect(response.body.scoped_query.kql).to.eql("_id: '1234'");
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
@ -156,6 +185,7 @@ export default function updateMaintenanceWindowTests({ getService }: FtrProvider
until: moment.utc().add(1, 'week').toISOString(),
},
category_ids: ['management'],
scoped_query: scopedQuery,
})
.expect(200);
@ -183,6 +213,7 @@ export default function updateMaintenanceWindowTests({ getService }: FtrProvider
...createParams,
r_rule: updatedRRule,
category_ids: null,
scoped_query: null,
})
.expect(200);
@ -194,6 +225,7 @@ export default function updateMaintenanceWindowTests({ getService }: FtrProvider
expect(response.body.data[0].id).to.eql(createdMaintenanceWindow.id);
expect(response.body.data[0].r_rule).to.eql(updatedRRule);
expect(response.body.data[0].category_ids).to.eql(null);
expect(response.body.data[0].scoped_query).to.eql(null);
});
it('should throw if updating maintenance window with invalid category ids', async () => {
@ -230,5 +262,46 @@ export default function updateMaintenanceWindowTests({ getService }: FtrProvider
.send({ category_ids: ['something-else'] })
.expect(400);
});
it('should throw if updating maintenance window with invalid scoped query', async () => {
const { body: createdMaintenanceWindow } = await supertest
.post(`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send({
title: 'test-maintenance-window',
duration: 60 * 60 * 1000, // 1 hr
r_rule: {
dtstart: new Date().toISOString(),
tzid: 'UTC',
freq: 2, // weekly
count: 1,
},
scoped_query: scopedQuery,
})
.expect(200);
objectRemover.add(
'space1',
createdMaintenanceWindow.id,
'rules/maintenance_window',
'alerting',
true
);
await supertest
.post(
`${getUrlPrefix('space1')}/internal/alerting/rules/maintenance_window/${
createdMaintenanceWindow.id
}`
)
.set('kbn-xsrf', 'foo')
.send({
scoped_query: {
kql: 'invalid_kql:',
filters: [],
},
})
.expect(400);
});
});
}