diff --git a/x-pack/platform/plugins/shared/maintenance_windows/moon.yml b/x-pack/platform/plugins/shared/maintenance_windows/moon.yml index 22587a8e1a878..172bddbb12e14 100644 --- a/x-pack/platform/plugins/shared/maintenance_windows/moon.yml +++ b/x-pack/platform/plugins/shared/maintenance_windows/moon.yml @@ -37,6 +37,8 @@ dependsOn: - '@kbn/datemath' - '@kbn/task-manager-plugin' - '@kbn/core-http-request-handler-context-server' + - '@kbn/rule-data-utils' + - '@kbn/alerts-as-data-utils' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/create/create_maintenance_window.test.ts b/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/create/create_maintenance_window.test.ts index 5e8fb81836e49..13947486d446d 100644 --- a/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/create/create_maintenance_window.test.ts +++ b/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/create/create_maintenance_window.test.ts @@ -254,7 +254,59 @@ describe('MaintenanceWindowClient - create', () => { `); }); - it('should throw if trying to create a maintenance window with invalid scoped query', async () => { + it.each([ + ['test*', 'test*'], + ['test rule*', 'test rule*'], + ])( + 'should generate wildcard query for keyword fields with KQL pattern: %s', + async (kqlPattern, expectedWildcardValue) => { + 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'], + scopedQuery: { + kql: `kibana.alert.rule.name: ${kqlPattern}`, + filters: [], + }, + }, + }); + + const createdAttributes = savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow; + const dsl = createdAttributes.scopedQuery!.dsl!; + const parsedDsl = JSON.parse(dsl); + + const wildcardClause = parsedDsl.bool.filter[0]; + expect(wildcardClause).toEqual({ + bool: { + should: [ + { + wildcard: { + 'kibana.alert.rule.name': { + value: expectedWildcardValue, + }, + }, + }, + ], + minimum_should_match: 1, + }, + }); + } + ); + + it('should throw if trying to create a maintenance window with invalid scope', async () => { jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z')); const mockMaintenanceWindow = getMockMaintenanceWindow({ @@ -306,4 +358,57 @@ describe('MaintenanceWindowClient - create', () => { - [data.categoryIds.1]: expected value to equal [null]" `); }); + it('should pass Query DSL wildcard filter through unchanged without requiring index pattern', 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); + + const wildcardQuery = { + wildcard: { + 'kibana.alert.rule.name': { + value: 'example*', + }, + }, + }; + + await createMaintenanceWindow(mockContext, { + data: { + title: mockMaintenanceWindow.title, + duration: mockMaintenanceWindow.duration, + rRule: mockMaintenanceWindow.rRule as CreateMaintenanceWindowParams['data']['rRule'], + scopedQuery: { + kql: '', + filters: [ + { + meta: { + disabled: false, + negate: false, + alias: null, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + query: wildcardQuery, + }, + ], + }, + }, + }); + + const createdAttributes = savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow; + const dsl = createdAttributes.scopedQuery!.dsl!; + const parsedDsl = JSON.parse(dsl); + + // Query DSL filters are passed through translateToQuery() as filter.query, + // so the wildcard query should appear unchanged in the output — no index pattern needed. + expect(parsedDsl.bool.filter[0]).toEqual(wildcardQuery); + }); }); diff --git a/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/create/create_maintenance_window.ts b/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/create/create_maintenance_window.ts index 379b82f752718..1220bade965b6 100644 --- a/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/create/create_maintenance_window.ts +++ b/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/create/create_maintenance_window.ts @@ -10,6 +10,7 @@ import { SavedObjectsUtils } from '@kbn/core/server'; import type { Filter } from '@kbn/es-query'; import { buildEsQuery } from '@kbn/es-query'; import { getEsQueryConfig } from '../../../lib/get_es_query_config'; +import { getAlertsDataViewBase } from '../../../lib/get_alerts_data_view_base'; import { generateMaintenanceWindowEvents } from '../../lib/generate_maintenance_window_events'; import type { MaintenanceWindowClientContext } from '../../../../common'; import { getScopedQueryErrorMessage } from '../../../../common'; @@ -39,12 +40,13 @@ export async function createMaintenanceWindow( } let scopedQueryWithGeneratedValue = scopedQuery; + const indexPattern = getAlertsDataViewBase(); try { if (scopedQuery) { const dsl = JSON.stringify( buildEsQuery( - undefined, + indexPattern, [{ query: scopedQuery.kql, language: 'kuery' }], scopedQuery.filters as Filter[], esQueryConfig diff --git a/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/update/update_maintenance_window.test.ts b/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/update/update_maintenance_window.test.ts index d53954ab4ccaf..2b28cb84cdc2c 100644 --- a/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/update/update_maintenance_window.test.ts +++ b/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/update/update_maintenance_window.test.ts @@ -394,7 +394,75 @@ describe('MaintenanceWindowClient - update', () => { ); }); - it('should remove maintenance window with scoped query', async () => { + it.each([ + ['test*', 'test*'], + ['test rule*', 'test rule*'], + ])( + 'should generate wildcard query for keyword fields with KQL pattern: %s', + async (kqlPattern, expectedWildcardValue) => { + jest.useFakeTimers().setSystemTime(new Date(firstTimestamp)); + + const mockMaintenanceWindow = getMockMaintenanceWindow({ + duration: 60 * 60 * 1000, + rRule: { + tzid: 'CET', + dtstart: '2023-03-26T00:00:00.000Z', + freq: Frequency.WEEKLY, + interval: 1, + count: 5, + } as MaintenanceWindow['rRule'], + events: [{ gte: '2023-03-26T00:00:00.000Z', lte: '2023-03-26T00:12:34.000Z' }], + 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: `kibana.alert.rule.name: ${kqlPattern}`, + filters: [], + }, + }, + }); + + const createdAttributes = savedObjectsClient.create.mock.calls[0][1] as MaintenanceWindow; + const dsl = createdAttributes.scopedQuery!.dsl!; + const parsedDsl = JSON.parse(dsl); + + const wildcardClause = parsedDsl.bool.filter[0]; + expect(wildcardClause).toEqual({ + bool: { + should: [ + { + wildcard: { + 'kibana.alert.rule.name': { + value: expectedWildcardValue, + }, + }, + }, + ], + minimum_should_match: 1, + }, + }); + } + ); + + it('should remove maintenance window with scope', async () => { jest.useFakeTimers().setSystemTime(new Date(firstTimestamp)); const modifiedEvents = [ diff --git a/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/update/update_maintenance_window.ts b/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/update/update_maintenance_window.ts index 23bb74d91b2cf..06e2f153afb73 100644 --- a/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/update/update_maintenance_window.ts +++ b/x-pack/platform/plugins/shared/maintenance_windows/server/application/methods/update/update_maintenance_window.ts @@ -12,6 +12,7 @@ import { buildEsQuery } from '@kbn/es-query'; import type { MaintenanceWindowClientContext } from '../../../../common'; import { getScopedQueryErrorMessage } from '../../../../common'; import { getEsQueryConfig } from '../../../lib/get_es_query_config'; +import { getAlertsDataViewBase } from '../../../lib/get_alerts_data_view_base'; import type { MaintenanceWindow } from '../../types'; import { generateMaintenanceWindowEvents, @@ -57,11 +58,12 @@ async function updateWithOCC( } let scopedQueryWithGeneratedValue = scopedQuery; + const indexPattern = getAlertsDataViewBase(); try { if (scopedQuery) { const dsl = JSON.stringify( buildEsQuery( - undefined, + indexPattern, [{ query: scopedQuery.kql, language: 'kuery' }], scopedQuery.filters as Filter[], esQueryConfig diff --git a/x-pack/platform/plugins/shared/maintenance_windows/server/lib/get_alerts_data_view_base.test.ts b/x-pack/platform/plugins/shared/maintenance_windows/server/lib/get_alerts_data_view_base.test.ts new file mode 100644 index 0000000000000..84afc23385abf --- /dev/null +++ b/x-pack/platform/plugins/shared/maintenance_windows/server/lib/get_alerts_data_view_base.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { ALERT_RULE_NAME, ALERT_STATUS, ALERT_DURATION } from '@kbn/rule-data-utils'; +import { getAlertsDataViewBase } from './get_alerts_data_view_base'; + +describe('getAlertsDataViewBase', () => { + it('should return a DataViewBase with the alerts index pattern', () => { + const dataView = getAlertsDataViewBase(); + expect(dataView.title).toBe('.alerts-*'); + expect(dataView.fields.length).toBeGreaterThan(0); + }); + + it('should map keyword fields with correct esTypes', () => { + const dataView = getAlertsDataViewBase(); + const ruleNameField = dataView.fields.find((f) => f.name === ALERT_RULE_NAME); + + expect(ruleNameField).toBeDefined(); + expect(ruleNameField!.type).toBe('string'); + expect(ruleNameField!.esTypes).toEqual(['keyword']); + }); + + it('should map date fields correctly', () => { + const dataView = getAlertsDataViewBase(); + const statusField = dataView.fields.find((f) => f.name === ALERT_STATUS); + + expect(statusField).toBeDefined(); + expect(statusField!.type).toBe('string'); + expect(statusField!.esTypes).toEqual(['keyword']); + }); + + it('should map long fields as number type', () => { + const dataView = getAlertsDataViewBase(); + const durationField = dataView.fields.find((f) => f.name === ALERT_DURATION); + + expect(durationField).toBeDefined(); + expect(durationField!.type).toBe('number'); + expect(durationField!.esTypes).toEqual(['long']); + }); +}); diff --git a/x-pack/platform/plugins/shared/maintenance_windows/server/lib/get_alerts_data_view_base.ts b/x-pack/platform/plugins/shared/maintenance_windows/server/lib/get_alerts_data_view_base.ts new file mode 100644 index 0000000000000..870e50cf9cc9f --- /dev/null +++ b/x-pack/platform/plugins/shared/maintenance_windows/server/lib/get_alerts_data_view_base.ts @@ -0,0 +1,39 @@ +/* + * 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 { alertFieldMap } from '@kbn/alerts-as-data-utils'; +import type { DataViewBase, DataViewFieldBase } from '@kbn/es-query'; + +const ES_TYPE_TO_KBN_TYPE: Record = { + keyword: 'string', + text: 'string', + long: 'number', + integer: 'number', + short: 'number', + byte: 'number', + double: 'number', + float: 'number', + half_float: 'number', + scaled_float: 'number', + date: 'date', + date_range: 'date_range', + boolean: 'boolean', + flattened: 'string', + version: 'string', + unmapped: 'string', +}; + +export function getAlertsDataViewBase(): DataViewBase { + const fields: DataViewFieldBase[] = Object.entries(alertFieldMap).map(([name, def]) => ({ + name, + type: ES_TYPE_TO_KBN_TYPE[def.type] ?? def.type, + esTypes: [def.type], + scripted: false, + })); + + return { title: '.alerts-*', fields }; +} diff --git a/x-pack/platform/plugins/shared/maintenance_windows/tsconfig.json b/x-pack/platform/plugins/shared/maintenance_windows/tsconfig.json index e2152f502c7d4..922ea3e6b009d 100644 --- a/x-pack/platform/plugins/shared/maintenance_windows/tsconfig.json +++ b/x-pack/platform/plugins/shared/maintenance_windows/tsconfig.json @@ -24,7 +24,9 @@ "@kbn/lazy-object", "@kbn/datemath", "@kbn/task-manager-plugin", - "@kbn/core-http-request-handler-context-server" + "@kbn/core-http-request-handler-context-server", + "@kbn/rule-data-utils", + "@kbn/alerts-as-data-utils" ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_scoped_query.ts b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_scoped_query.ts index e5a304db8a874..21b909eaef7c4 100644 --- a/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_scoped_query.ts +++ b/x-pack/platform/test/alerting_api_integration/spaces_only/tests/alerting/group3/maintenance_window_scoped_query.ts @@ -672,5 +672,185 @@ export default function maintenanceWindowScopedQueryTests({ getService }: FtrPro } }); }); + + it('should associate alerts when KQL uses trailing wildcard on keyword field', async () => { + await createMaintenanceWindow({ + supertest, + objectRemover, + overwrites: { + scoped_query: { + kql: 'kibana.alert.rule.name: rule*', + filters: [], + }, + category_ids: ['management'], + }, + }); + + const action = await await createAction({ supertest, objectRemover }); + + const { body: rule } = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + name: 'rule-test-rule', + rule_type_id: 'test.always-firing-alert-as-data', + schedule: { interval: '24h' }, + tags: ['test'], + throttle: undefined, + notify_when: 'onActiveAlert', + params: { + index: alertAsDataIndex, + reference: 'test', + }, + actions: [ + { id: action.id, group: 'default', params: {} }, + { id: action.id, group: 'recovered', params: {} }, + ], + }) + ) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + await getRuleEvents({ + id: rule.id, + activeInstance: 2, + retry, + getService, + }); + + await expectNoActionsFired({ id: rule.id, supertest, retry }); + }); + + it('should associate alerts when Query DSL wildcard filter includes value with spaces', async () => { + const wildcardDslFilter = { + meta: { + type: 'custom', + key: 'query', + disabled: false, + negate: false, + }, + $state: { store: 'appState' as const }, + query: { + wildcard: { + 'kibana.alert.rule.name': 'test rule*', + }, + }, + }; + + await createMaintenanceWindow({ + supertest, + objectRemover, + overwrites: { + scoped_query: { + kql: '', + filters: [wildcardDslFilter], + }, + category_ids: ['management'], + }, + }); + + const action = await await createAction({ supertest, objectRemover }); + + const { body: rule } = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + name: 'test rule with spaces', + rule_type_id: 'test.always-firing-alert-as-data', + schedule: { interval: '24h' }, + tags: ['test'], + throttle: undefined, + notify_when: 'onActiveAlert', + params: { + index: alertAsDataIndex, + reference: 'test', + }, + actions: [ + { id: action.id, group: 'default', params: {} }, + { id: action.id, group: 'recovered', params: {} }, + ], + }) + ) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + await getRuleEvents({ + id: rule.id, + activeInstance: 2, + retry, + getService, + }); + + await expectNoActionsFired({ id: rule.id, supertest, retry }); + }); + + it('should associate alerts when using Query DSL wildcard filter', async () => { + const wildcardDslFilter = { + meta: { + type: 'custom', + key: 'query', + disabled: false, + negate: false, + }, + $state: { store: 'appState' as const }, + query: { + wildcard: { + 'kibana.alert.rule.name': 'example*', + }, + }, + }; + + await createMaintenanceWindow({ + supertest, + objectRemover, + overwrites: { + scoped_query: { + kql: '', + filters: [wildcardDslFilter], + }, + category_ids: ['management'], + }, + }); + + const action = await await createAction({ supertest, objectRemover }); + + const { body: rule } = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + name: 'example-rule-name', + rule_type_id: 'test.always-firing-alert-as-data', + schedule: { interval: '24h' }, + tags: ['test'], + throttle: undefined, + notify_when: 'onActiveAlert', + params: { + index: alertAsDataIndex, + reference: 'test', + }, + actions: [ + { id: action.id, group: 'default', params: {} }, + { id: action.id, group: 'recovered', params: {} }, + ], + }) + ) + .expect(200); + + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + + await getRuleEvents({ + id: rule.id, + activeInstance: 2, + retry, + getService, + }); + + await expectNoActionsFired({ id: rule.id, supertest, retry }); + }); }); }