[Response Ops][Alerting] Adding dangerouslyCreateAlertsInAllSpaces rule type option for alert creation (#224507)

Resolves https://github.com/elastic/kibana/issues/222104

## Summary

Adds optional flag when registering a rule type for "dangerously
creating alerts in all spaces". If a rule type opts into this flag,
alerts created during rule execution will persist the `kibana.space_ids`
field as `"*"` instead of the space ID of the rule. Note that we store
`kibana.space_ids` as a string array, so the final alert document will
have

```
'kibana.space_ids': ['*']
```

This PR just adds the flag and updates the code to respect the flag. It
does not opt any rule types into using it. You can look at the
functional tests to see example test rule types that use it.

Because the streams rule type that we expect to be the first user of
this flag uses the `persistenceRuleTypeWrapper` in the rule registry for
writing alerts, we also had to update the rule registry code.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Ying Mao 2025-06-23 13:41:27 -04:00 committed by GitHub
parent 3af496d11a
commit e1b02be28b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 768 additions and 37 deletions

View file

@ -64,6 +64,7 @@ import {
getMaintenanceWindowAlertsQuery,
getContinualAlertsQuery,
isAlertImproving,
shouldCreateAlertsInAllSpaces,
} from './lib';
import { isValidAlertIndexName } from '../alerts_service';
import { resolveAlertConflicts } from './lib/alert_conflict_resolver';
@ -488,6 +489,11 @@ export class AlertsClient<
const currentTime = this.startedAtString ?? new Date().toISOString();
const esClient = await this.options.elasticsearchClientPromise;
const createAlertsInAllSpaces = shouldCreateAlertsInAllSpaces({
ruleTypeId: this.ruleType.id,
ruleTypeAlertDef: this.ruleType.alerts,
logger: this.options.logger,
});
const { rawActiveAlerts, rawRecoveredAlerts } = this.getRawAlertInstancesForState();
const activeAlerts = this.legacyAlertsClient.getProcessedAlerts(ALERT_STATUS_ACTIVE);
@ -526,6 +532,7 @@ export class AlertsClient<
timestamp: currentTime,
payload: this.reportedAlerts[id],
kibanaVersion: this.options.kibanaVersion,
dangerouslyCreateAlertsInAllSpaces: createAlertsInAllSpaces,
})
);
} else {
@ -548,6 +555,7 @@ export class AlertsClient<
timestamp: currentTime,
payload: this.reportedAlerts[id],
kibanaVersion: this.options.kibanaVersion,
dangerouslyCreateAlertsInAllSpaces: createAlertsInAllSpaces,
})
);
}
@ -582,6 +590,7 @@ export class AlertsClient<
payload: this.reportedAlerts[id],
recoveryActionGroup: this.options.ruleType.recoveryActionGroup.id,
kibanaVersion: this.options.kibanaVersion,
dangerouslyCreateAlertsInAllSpaces: createAlertsInAllSpaces,
})
: buildUpdatedRecoveredAlert<AlertData>({
alert: trackedAlert,

View file

@ -8,5 +8,9 @@
export { type LegacyAlertsClientParams, LegacyAlertsClient } from './legacy_alerts_client';
export { AlertsClient } from './alerts_client';
export type { AlertRuleData } from './types';
export { sanitizeBulkErrorResponse, initializeAlertsClient } from './lib';
export {
sanitizeBulkErrorResponse,
initializeAlertsClient,
shouldCreateAlertsInAllSpaces,
} from './lib';
export { AlertsClientError } from './alerts_client_error';

View file

@ -105,6 +105,41 @@ describe('buildNewAlert', () => {
});
});
test(`should set kibana.space_ids to '*' if dangerouslyCreateAlertsInAllSpaces=true`, () => {
const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A');
legacyAlert.scheduleActions('default');
expect(
buildNewAlert<{}, {}, {}, 'default', 'recovered'>({
legacyAlert,
rule: alertRule,
timestamp: '2023-03-28T12:27:28.159Z',
kibanaVersion: '8.9.0',
dangerouslyCreateAlertsInAllSpaces: true,
})
).toEqual({
...alertRule,
[TIMESTAMP]: '2023-03-28T12:27:28.159Z',
[EVENT_ACTION]: 'open',
[EVENT_KIND]: 'signal',
[ALERT_ACTION_GROUP]: 'default',
[ALERT_CONSECUTIVE_MATCHES]: 0,
[ALERT_FLAPPING]: false,
[ALERT_FLAPPING_HISTORY]: [],
[ALERT_INSTANCE_ID]: 'alert-A',
[ALERT_MAINTENANCE_WINDOW_IDS]: [],
[ALERT_PENDING_RECOVERED_COUNT]: 0,
[ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-28T12:27:28.159Z',
[ALERT_SEVERITY_IMPROVING]: false,
[ALERT_STATUS]: 'active',
[ALERT_UUID]: legacyAlert.getUuid(),
[ALERT_WORKFLOW_STATUS]: 'open',
[SPACE_IDS]: ['*'],
[VERSION]: '8.9.0',
[TAGS]: ['rule-', '-tags'],
});
});
test('should include flapping history and maintenance window ids if set', () => {
const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A');
legacyAlert.scheduleActions('default');

View file

@ -52,6 +52,7 @@ interface BuildNewAlertOpts<
runTimestamp?: string;
timestamp: string;
kibanaVersion: string;
dangerouslyCreateAlertsInAllSpaces?: boolean;
}
/**
@ -72,6 +73,7 @@ export const buildNewAlert = <
timestamp,
payload,
kibanaVersion,
dangerouslyCreateAlertsInAllSpaces,
}: BuildNewAlertOpts<
AlertData,
LegacyState,
@ -109,7 +111,7 @@ export const buildNewAlert = <
[ALERT_TIME_RANGE]: { gte: legacyAlert.getState().start },
}
: {}),
[SPACE_IDS]: rule[SPACE_IDS],
[SPACE_IDS]: dangerouslyCreateAlertsInAllSpaces === true ? ['*'] : rule[SPACE_IDS],
[VERSION]: kibanaVersion,
[TAGS]: Array.from(
new Set([...((cleanedPayload?.tags as string[]) ?? []), ...(rule[ALERT_RULE_TAGS] ?? [])])

View file

@ -244,6 +244,88 @@ for (const flattened of [true, false]) {
});
});
test(`should return alert document with kibana.space_ids set to '*' if dangerouslyCreateAlertsInAllSpaces=true`, () => {
const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('alert-A', {
meta: { uuid: 'abcdefg' },
});
legacyAlert
.scheduleActions('error')
.replaceState({ start: '2023-03-28T12:27:28.159Z', duration: '36000000' });
legacyAlert.setFlappingHistory([false, false, true, true]);
legacyAlert.setMaintenanceWindowIds(['maint-xyz']);
const alert = flattened
? {
...existingAlert,
[ALERT_FLAPPING_HISTORY]: [true, false, false, false, true, true],
[ALERT_MAINTENANCE_WINDOW_IDS]: ['maint-1', 'maint-321'],
}
: {
...existingAlert,
kibana: {
// @ts-expect-error
...existingAlert.kibana,
alert: {
// @ts-expect-error
...existingAlert.kibana.alert,
flapping_history: [true, false, false, false, true, true],
maintenance_window_ids: ['maint-1', 'maint-321'],
},
},
};
expect(
buildOngoingAlert<{}, {}, {}, 'error' | 'warning', 'recovered'>({
// @ts-expect-error
alert,
legacyAlert,
rule: alertRule,
isImproving: null,
timestamp: '2023-03-29T12:27:28.159Z',
kibanaVersion: '8.9.0',
dangerouslyCreateAlertsInAllSpaces: true,
})
).toEqual({
...alertRule,
[TIMESTAMP]: '2023-03-29T12:27:28.159Z',
[ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z',
[EVENT_ACTION]: 'active',
[ALERT_ACTION_GROUP]: 'error',
[ALERT_CONSECUTIVE_MATCHES]: 0,
[ALERT_FLAPPING]: false,
[ALERT_FLAPPING_HISTORY]: [false, false, true, true],
[ALERT_MAINTENANCE_WINDOW_IDS]: ['maint-xyz'],
[ALERT_PENDING_RECOVERED_COUNT]: 0,
[ALERT_PREVIOUS_ACTION_GROUP]: 'error',
[ALERT_STATUS]: 'active',
[ALERT_WORKFLOW_STATUS]: 'open',
[ALERT_DURATION]: 36000,
[ALERT_TIME_RANGE]: { gte: '2023-03-28T12:27:28.159Z' },
[SPACE_IDS]: ['*'],
[VERSION]: '8.9.0',
[TAGS]: ['rule-', '-tags'],
...(flattened
? {
[EVENT_KIND]: 'signal',
[ALERT_INSTANCE_ID]: 'alert-A',
[ALERT_START]: '2023-03-28T12:27:28.159Z',
[ALERT_UUID]: 'abcdefg',
}
: {
event: {
kind: 'signal',
},
kibana: {
alert: {
instance: { id: 'alert-A' },
start: '2023-03-28T12:27:28.159Z',
uuid: 'abcdefg',
},
},
}),
});
});
test('should return alert document with updated isImproving', () => {
const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('alert-A', {
meta: { uuid: 'abcdefg' },

View file

@ -50,6 +50,7 @@ interface BuildOngoingAlertOpts<
runTimestamp?: string;
timestamp: string;
kibanaVersion: string;
dangerouslyCreateAlertsInAllSpaces?: boolean;
}
/**
@ -72,6 +73,7 @@ export const buildOngoingAlert = <
runTimestamp,
timestamp,
kibanaVersion,
dangerouslyCreateAlertsInAllSpaces,
}: BuildOngoingAlertOpts<
AlertData,
LegacyState,
@ -122,7 +124,7 @@ export const buildOngoingAlert = <
: {}),
...(isImproving != null ? { [ALERT_SEVERITY_IMPROVING]: isImproving } : {}),
[ALERT_PREVIOUS_ACTION_GROUP]: get(alert, ALERT_ACTION_GROUP),
[SPACE_IDS]: rule[SPACE_IDS],
[SPACE_IDS]: dangerouslyCreateAlertsInAllSpaces === true ? ['*'] : rule[SPACE_IDS],
[VERSION]: kibanaVersion,
[TAGS]: Array.from(
new Set([

View file

@ -185,6 +185,74 @@ for (const flattened of [true, false]) {
});
});
test(`should return alert document with kibana.space_ids set to '*' if dangerouslyCreateAlertsInAllSpaces=true`, () => {
const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A', {
meta: { uuid: 'abcdefg' },
});
legacyAlert.scheduleActions('default').replaceState({
start: '2023-03-28T12:27:28.159Z',
end: '2023-03-30T12:27:28.159Z',
duration: '36000000',
});
expect(
buildRecoveredAlert<{}, {}, {}, 'default', 'recovered'>({
// @ts-expect-error
alert: existingAlert,
legacyAlert,
rule: alertRule,
recoveryActionGroup: 'recovered',
timestamp: '2023-03-29T12:27:28.159Z',
kibanaVersion: '8.9.0',
dangerouslyCreateAlertsInAllSpaces: true,
})
).toEqual({
[TIMESTAMP]: '2023-03-29T12:27:28.159Z',
[ALERT_RULE_EXECUTION_TIMESTAMP]: '2023-03-29T12:27:28.159Z',
// @ts-ignore
[ALERT_RULE_EXECUTION_UUID]: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
[EVENT_ACTION]: 'close',
[ALERT_ACTION_GROUP]: 'recovered',
[ALERT_CONSECUTIVE_MATCHES]: 0,
[ALERT_FLAPPING]: false,
[ALERT_FLAPPING_HISTORY]: [],
[ALERT_SEVERITY_IMPROVING]: true,
[ALERT_PREVIOUS_ACTION_GROUP]: 'default',
[ALERT_MAINTENANCE_WINDOW_IDS]: [],
[ALERT_PENDING_RECOVERED_COUNT]: 0,
[ALERT_STATUS]: 'recovered',
[ALERT_WORKFLOW_STATUS]: 'open',
[ALERT_DURATION]: 36000,
[ALERT_START]: '2023-03-28T12:27:28.159Z',
[ALERT_END]: '2023-03-30T12:27:28.159Z',
[ALERT_TIME_RANGE]: { gte: '2023-03-28T12:27:28.159Z', lte: '2023-03-30T12:27:28.159Z' },
// @ts-expect-error
[SPACE_IDS]: ['*'],
[VERSION]: '8.9.0',
[TAGS]: ['rule-', '-tags'],
...(flattened
? {
...alertRule,
[EVENT_KIND]: 'signal',
[ALERT_INSTANCE_ID]: 'alert-A',
[ALERT_UUID]: 'abcdefg',
[SPACE_IDS]: ['*'],
}
: {
event: {
kind: 'signal',
},
kibana: {
alert: {
instance: { id: 'alert-A' },
rule: omit(rule, 'execution'),
uuid: 'abcdefg',
},
},
}),
});
});
test('should return alert document with updated payload if specified', () => {
const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A', {
meta: { uuid: 'abcdefg' },

View file

@ -54,6 +54,7 @@ interface BuildRecoveredAlertOpts<
payload?: DeepPartial<AlertData>;
timestamp: string;
kibanaVersion: string;
dangerouslyCreateAlertsInAllSpaces?: boolean;
}
/**
@ -76,6 +77,7 @@ export const buildRecoveredAlert = <
runTimestamp,
recoveryActionGroup,
kibanaVersion,
dangerouslyCreateAlertsInAllSpaces,
}: BuildRecoveredAlertOpts<
AlertData,
LegacyState,
@ -126,7 +128,7 @@ export const buildRecoveredAlert = <
}
: {}),
[SPACE_IDS]: rule[SPACE_IDS],
[SPACE_IDS]: dangerouslyCreateAlertsInAllSpaces === true ? ['*'] : rule[SPACE_IDS],
// Set latest kibana version
[VERSION]: kibanaVersion,
[TAGS]: Array.from(

View file

@ -20,3 +20,4 @@ export { expandFlattenedAlert } from './format_alert';
export { sanitizeBulkErrorResponse } from './sanitize_bulk_response';
export { initializeAlertsClient } from './initialize_alerts_client';
export { isAlertImproving } from './is_alert_improving';
export { shouldCreateAlertsInAllSpaces } from './should_create_alerts_in_all_spaces';

View file

@ -0,0 +1,105 @@
/*
* 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 { shouldCreateAlertsInAllSpaces } from './should_create_alerts_in_all_spaces';
import { loggingSystemMock } from '@kbn/core/server/mocks';
const logger = loggingSystemMock.createLogger();
describe('shouldCreateAlertsInAllSpaces', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns false if alert definition is undefined', () => {
expect(
shouldCreateAlertsInAllSpaces({
ruleTypeId: 'test.rule-type',
logger,
})
).toBe(false);
expect(logger.warn).not.toHaveBeenCalled();
});
it('returns false if dangerouslyCreateAlertsInAllSpaces is undefined', () => {
expect(
shouldCreateAlertsInAllSpaces({
ruleTypeId: 'test.rule-type',
ruleTypeAlertDef: {
context: 'test',
mappings: { fieldMap: { field: { type: 'keyword', required: false } } },
shouldWrite: true,
isSpaceAware: true,
},
logger,
})
).toBe(false);
expect(logger.warn).not.toHaveBeenCalled();
});
it('returns false if dangerouslyCreateAlertsInAllSpaces is false', () => {
expect(
shouldCreateAlertsInAllSpaces({
ruleTypeId: 'test.rule-type',
ruleTypeAlertDef: {
context: 'test',
mappings: { fieldMap: { field: { type: 'keyword', required: false } } },
shouldWrite: true,
dangerouslyCreateAlertsInAllSpaces: false,
},
logger,
})
).toBe(false);
expect(logger.warn).not.toHaveBeenCalled();
});
it('returns true if dangerouslyCreateAlertsInAllSpaces is true and isSpaceAware is undefined', () => {
expect(
shouldCreateAlertsInAllSpaces({
ruleTypeId: 'test.rule-type',
ruleTypeAlertDef: {
context: 'test',
mappings: { fieldMap: { field: { type: 'keyword', required: false } } },
shouldWrite: true,
dangerouslyCreateAlertsInAllSpaces: true,
},
logger,
})
).toBe(true);
expect(logger.warn).not.toHaveBeenCalled();
});
it('returns true if dangerouslyCreateAlertsInAllSpaces is true and isSpaceAware is false', () => {
expect(
shouldCreateAlertsInAllSpaces({
ruleTypeId: 'test.rule-type',
ruleTypeAlertDef: {
context: 'test',
mappings: { fieldMap: { field: { type: 'keyword', required: false } } },
shouldWrite: true,
isSpaceAware: false,
dangerouslyCreateAlertsInAllSpaces: true,
},
logger,
})
).toBe(true);
expect(logger.warn).not.toHaveBeenCalled();
});
it('returns false and logs warning if dangerouslyCreateAlertsInAllSpaces is true and isSpaceAware is true', () => {
expect(
shouldCreateAlertsInAllSpaces({
ruleTypeId: 'test.rule-type',
ruleTypeAlertDef: {
context: 'test',
mappings: { fieldMap: { field: { type: 'keyword', required: false } } },
shouldWrite: true,
isSpaceAware: true,
dangerouslyCreateAlertsInAllSpaces: true,
},
logger,
})
).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
`Rule type \"test.rule-type\" is space aware but also has \"dangerouslyCreateAlertsInAllSpaces\" set to true. This is not supported so alerts will be created with the space ID of the rule.`
);
});
});

View file

@ -0,0 +1,36 @@
/*
* 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 { Logger } from '@kbn/core/server';
import type { UntypedRuleTypeAlerts } from '../../types';
interface ShouldCreateAlertsInAllSpacesOpts {
ruleTypeId: string;
ruleTypeAlertDef?: UntypedRuleTypeAlerts;
logger: Logger;
}
export const shouldCreateAlertsInAllSpaces = ({
ruleTypeId,
ruleTypeAlertDef,
logger,
}: ShouldCreateAlertsInAllSpacesOpts): boolean => {
const dangerouslyCreateAlertsInAllSpaces = ruleTypeAlertDef?.dangerouslyCreateAlertsInAllSpaces;
const isSpaceAware = ruleTypeAlertDef?.isSpaceAware;
if (dangerouslyCreateAlertsInAllSpaces === true) {
if (isSpaceAware === true) {
logger.warn(
`Rule type "${ruleTypeId}" is space aware but also has "dangerouslyCreateAlertsInAllSpaces" set to true. This is not supported so alerts will be created with the space ID of the rule.`
);
return false;
} else {
// alerts will be created for all spaces
return true;
}
}
return false;
};

View file

@ -70,7 +70,11 @@ export {
isValidAlertIndexName,
InstallShutdownError,
} from './alerts_service';
export { sanitizeBulkErrorResponse, AlertsClientError } from './alerts_client';
export {
sanitizeBulkErrorResponse,
AlertsClientError,
shouldCreateAlertsInAllSpaces,
} from './alerts_client';
export { getDataStreamAdapter } from './alerts_service/lib/data_stream_adapter';
export type { ConnectorAdapter } from './connector_adapters/types';

View file

@ -273,6 +273,12 @@ export interface IRuleTypeAlerts<AlertData extends RuleAlertData = never> {
*/
isSpaceAware?: boolean;
/**
* Optional flag to indicate that these alerts should not be space aware. When set
* to true, alerts for this rule type will be created with the `*` space id.
*/
dangerouslyCreateAlertsInAllSpaces?: boolean;
/**
* Optional secondary alias to use. This alias should not include the namespace.
*/
@ -363,6 +369,8 @@ export type UntypedRuleType = RuleType<
AlertInstanceContext
>;
export type UntypedRuleTypeAlerts = IRuleTypeAlerts<RuleAlertData>;
export interface RuleMeta extends SavedObjectAttributes {
versionApiKeyLastmodified?: string;
}

View file

@ -113,8 +113,11 @@ describe('AlertsClient', () => {
"type": "function",
},
Object {
"term": Object {
"kibana.space_ids": "space-1",
"terms": Object {
"kibana.space_ids": Array [
"space-1",
"*",
],
},
},
Object {
@ -204,8 +207,11 @@ describe('AlertsClient', () => {
"type": "function",
},
Object {
"term": Object {
"kibana.space_ids": "space-1",
"terms": Object {
"kibana.space_ids": Array [
"space-1",
"*",
],
},
},
Object {

View file

@ -304,8 +304,11 @@ describe('find()', () => {
},
},
Object {
"term": Object {
"kibana.space_ids": "test_default_space_id",
"terms": Object {
"kibana.space_ids": Array [
"test_default_space_id",
"*",
],
},
},
Object {
@ -524,8 +527,11 @@ describe('find()', () => {
},
},
Object {
"term": Object {
"kibana.space_ids": "test_default_space_id",
"terms": Object {
"kibana.space_ids": Array [
"test_default_space_id",
"*",
],
},
},
],

View file

@ -149,8 +149,11 @@ describe('get()', () => {
},
},
Object {
"term": Object {
"kibana.space_ids": "test_default_space_id",
"terms": Object {
"kibana.space_ids": Array [
"test_default_space_id",
"*",
],
},
},
],

View file

@ -251,8 +251,11 @@ describe('getAlertSummary()', () => {
},
},
Object {
"term": Object {
"kibana.space_ids": "test_default_space_id",
"terms": Object {
"kibana.space_ids": Array [
"test_default_space_id",
"*",
],
},
},
Object {

View file

@ -8,8 +8,8 @@ import { getSpacesFilter } from '.';
describe('getSpacesFilter()', () => {
it('should return a spaces filter', () => {
expect(getSpacesFilter('1')).toStrictEqual({
term: {
'kibana.space_ids': '1',
terms: {
'kibana.space_ids': ['1', '*'],
},
});
});

View file

@ -7,5 +7,5 @@
import { SPACE_IDS } from '../../common/technical_rule_data_field_names';
export function getSpacesFilter(spaceId?: string) {
return spaceId ? { term: { [SPACE_IDS]: spaceId } } : undefined;
return spaceId ? { terms: { [SPACE_IDS]: [spaceId, '*'] } } : undefined;
}

View file

@ -8,7 +8,10 @@
import { sortBy } from 'lodash';
import dateMath from '@elastic/datemath';
import type { estypes } from '@elastic/elasticsearch';
import type { RuleExecutorOptions } from '@kbn/alerting-plugin/server';
import {
shouldCreateAlertsInAllSpaces,
type RuleExecutorOptions,
} from '@kbn/alerting-plugin/server';
import { chunk, partition } from 'lodash';
import {
ALERT_INSTANCE_ID,
@ -28,6 +31,7 @@ import {
} from '@kbn/rule-data-utils';
import { mapKeys, snakeCase } from 'lodash/fp';
import type { UntypedRuleTypeAlerts } from '@kbn/alerting-plugin/server/types';
import type { IRuleDataClient } from '..';
import { getCommonAlertFields } from './get_common_alert_fields';
import type { CreatePersistenceRuleTypeWrapper } from './persistence_types';
@ -56,13 +60,15 @@ const augmentAlerts = async <T>({
options,
kibanaVersion,
currentTimeOverride,
dangerouslyCreateAlertsInAllSpaces,
}: {
alerts: Array<{ _id: string; _source: T }>;
options: RuleExecutorOptions<any, any, any, any, any>;
kibanaVersion: string;
currentTimeOverride: Date | undefined;
dangerouslyCreateAlertsInAllSpaces?: boolean;
}) => {
const commonRuleFields = getCommonAlertFields(options);
const commonRuleFields = getCommonAlertFields(options, dangerouslyCreateAlertsInAllSpaces);
const maintenanceWindowIds: string[] =
alerts.length > 0 ? await options.services.getMaintenanceWindowIds() : [];
@ -246,6 +252,11 @@ export const getUpdatedSuppressionBoundaries = <T extends SuppressionBoundaries>
export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper =
({ logger, ruleDataClient, formatAlert }) =>
(type) => {
const createAlertsInAllSpaces = shouldCreateAlertsInAllSpaces({
ruleTypeId: type.id,
ruleTypeAlertDef: type.alerts as unknown as UntypedRuleTypeAlerts,
logger,
});
return {
...type,
executor: async (options) => {
@ -307,6 +318,7 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
options,
kibanaVersion: ruleDataClient.kibanaVersion,
currentTimeOverride: undefined,
dangerouslyCreateAlertsInAllSpaces: createAlertsInAllSpaces,
});
const response = await ruleDataClientWriter.bulk({
@ -573,6 +585,7 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
options,
kibanaVersion: ruleDataClient.kibanaVersion,
currentTimeOverride,
dangerouslyCreateAlertsInAllSpaces: createAlertsInAllSpaces,
});
const bulkResponse = await ruleDataClientWriter.bulk({

View file

@ -0,0 +1,97 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
ALERT_RULE_PARAMETERS,
ALERT_RULE_CATEGORY,
ALERT_RULE_CONSUMER,
ALERT_RULE_EXECUTION_UUID,
ALERT_RULE_NAME,
ALERT_RULE_PRODUCER,
ALERT_RULE_REVISION,
ALERT_RULE_TYPE_ID,
ALERT_RULE_UUID,
SPACE_IDS,
ALERT_RULE_TAGS,
TIMESTAMP,
} from '@kbn/rule-data-utils';
import { getCommonAlertFields } from './get_common_alert_fields';
describe('getCommonAlertFields', () => {
test('should correctly return common alert fields', () => {
expect(
getCommonAlertFields({
executionId: '1234',
params: { foo: 'bar' },
// @ts-expect-error - incomplete rule definition for testing
rule: {
ruleTypeName: 'Test Rule',
consumer: 'test-consumer',
name: 'Test Rule Name',
producer: 'test-producer',
revision: 1,
ruleTypeId: 'test.rule-type',
id: 'rule-id',
tags: ['test-tag'],
},
startedAt: new Date('2023-10-01T00:00:00Z'),
spaceId: 'default',
})
).toEqual({
[ALERT_RULE_PARAMETERS]: { foo: 'bar' },
[ALERT_RULE_CATEGORY]: 'Test Rule',
[ALERT_RULE_CONSUMER]: 'test-consumer',
[ALERT_RULE_EXECUTION_UUID]: '1234',
[ALERT_RULE_NAME]: 'Test Rule Name',
[ALERT_RULE_PRODUCER]: 'test-producer',
[ALERT_RULE_REVISION]: 1,
[ALERT_RULE_TYPE_ID]: 'test.rule-type',
[ALERT_RULE_UUID]: 'rule-id',
[SPACE_IDS]: ['default'],
[ALERT_RULE_TAGS]: ['test-tag'],
[TIMESTAMP]: '2023-10-01T00:00:00.000Z',
});
});
test(`should set kibana.space_ids to '*' when dangerouslyCreateAlertsInAllSpaces=true`, () => {
expect(
getCommonAlertFields(
{
executionId: '1234',
params: { foo: 'bar' },
// @ts-expect-error - incomplete rule definition for testing
rule: {
ruleTypeName: 'Test Rule',
consumer: 'test-consumer',
name: 'Test Rule Name',
producer: 'test-producer',
revision: 1,
ruleTypeId: 'test.rule-type',
id: 'rule-id',
tags: ['test-tag'],
},
startedAt: new Date('2023-10-01T00:00:00Z'),
spaceId: 'default',
},
true
)
).toEqual({
[ALERT_RULE_PARAMETERS]: { foo: 'bar' },
[ALERT_RULE_CATEGORY]: 'Test Rule',
[ALERT_RULE_CONSUMER]: 'test-consumer',
[ALERT_RULE_EXECUTION_UUID]: '1234',
[ALERT_RULE_NAME]: 'Test Rule Name',
[ALERT_RULE_PRODUCER]: 'test-producer',
[ALERT_RULE_REVISION]: 1,
[ALERT_RULE_TYPE_ID]: 'test.rule-type',
[ALERT_RULE_UUID]: 'rule-id',
[SPACE_IDS]: ['*'],
[ALERT_RULE_TAGS]: ['test-tag'],
[TIMESTAMP]: '2023-10-01T00:00:00.000Z',
});
});
});

View file

@ -24,7 +24,8 @@ import type { RuleExecutorOptions } from '@kbn/alerting-plugin/server';
import type { CommonAlertFieldsLatest } from '../../common/schemas';
export const getCommonAlertFields = (
options: RuleExecutorOptions<any, any, any, any, any>
options: RuleExecutorOptions<any, any, any, any, any>,
dangerouslyCreateAlertsInAllSpaces?: boolean
): CommonAlertFieldsLatest => {
return {
[ALERT_RULE_PARAMETERS]: options.params,
@ -36,7 +37,7 @@ export const getCommonAlertFields = (
[ALERT_RULE_REVISION]: options.rule.revision,
[ALERT_RULE_TYPE_ID]: options.rule.ruleTypeId,
[ALERT_RULE_UUID]: options.rule.id,
[SPACE_IDS]: [options.spaceId],
[SPACE_IDS]: dangerouslyCreateAlertsInAllSpaces === true ? ['*'] : [options.spaceId],
[ALERT_RULE_TAGS]: options.rule.tags,
[TIMESTAMP]: options.startedAt.toISOString(),
};

View file

@ -80,6 +80,8 @@ const testRuleTypes = [
'test.waitingRule',
'test.patternFiringAutoRecoverFalse',
'test.severity',
'test.dangerouslyCreateAlertsInAllSpaces',
'test.persistenceDangerouslyCreateAlertsInAllSpaces',
];
const testAlertingFeatures = testRuleTypes.map((ruleTypeId) => ({

View file

@ -19,6 +19,9 @@ import type {
RuleTypeParams,
} from '@kbn/alerting-plugin/server';
import { ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers';
import { Dataset, createPersistenceRuleTypeWrapper } from '@kbn/rule-registry-plugin/server';
import { mappingFromFieldMap } from '@kbn/alerting-plugin/common';
import { alertFieldMap } from '@kbn/alerts-as-data-utils';
import type { FixtureStartDeps, FixtureSetupDeps } from './plugin';
export const EscapableStrings = {
@ -1295,6 +1298,112 @@ export function defineRuleTypes(
{ alerting, ruleRegistry }: Pick<FixtureSetupDeps, 'alerting' | 'ruleRegistry'>,
logger: Logger
) {
const ruleDataClient = ruleRegistry.ruleDataService.initializeIndex({
feature: 'AlertingExample',
registrationContext: 'test.dangerouslycreatealertsinallspaces',
dataset: Dataset.alerts,
componentTemplateRefs: [],
componentTemplates: [
{
name: 'mappings',
mappings: mappingFromFieldMap(alertFieldMap, false),
},
],
});
const persistenceRuleTypeWrapper = createPersistenceRuleTypeWrapper({
ruleDataClient,
logger,
formatAlert: undefined,
});
const dangerouslyCreateAlertsInAllSpacesPersistenceRuleType = persistenceRuleTypeWrapper({
id: 'test.persistenceDangerouslyCreateAlertsInAllSpaces',
name: 'Test Persistence Rule Type - All Spaces',
validate: { params: schema.any() },
defaultActionGroupId: 'default',
actionGroups: [{ id: 'default', name: 'Default' }],
minimumLicenseRequired: 'basic',
category: 'kibana',
producer: 'alertsFixture',
solution: 'stack',
isExportable: true,
async executor(ruleExecutorOptions) {
const { services } = ruleExecutorOptions;
const { alertWithPersistence } = services;
// generate some alerts
const alerts = range(0, 5).map((i) => {
const id = uuidv4();
return {
_id: id,
_source: {
original_source: {
_id: `${id}-${i}`,
'@timestamp': new Date().toISOString(),
},
},
};
});
await alertWithPersistence(alerts, true, 100);
return { state: {} };
},
autoRecoverAlerts: false,
alerts: {
context: 'test.dangerouslycreatealertsinallspaces',
mappings: { dynamic: false, fieldMap: { ...alertFieldMap } },
shouldWrite: false,
isSpaceAware: false,
dangerouslyCreateAlertsInAllSpaces: true,
},
});
const dangerouslyCreateAlertsInAllSpacesRuleType: RuleType<
{},
{},
{},
{},
{},
'default',
'recovered',
{ original_source: { _id: string } }
> = {
id: 'test.dangerouslyCreateAlertsInAllSpaces',
name: 'Test Alerts Client Rule Type - All Spaces',
validate: { params: schema.any() },
defaultActionGroupId: 'default',
actionGroups: [{ id: 'default', name: 'Default' }],
minimumLicenseRequired: 'basic',
category: 'kibana',
producer: 'alertsFixture',
solution: 'stack',
isExportable: true,
async executor(ruleExecutorOptions) {
const { services } = ruleExecutorOptions;
const { alertsClient } = services;
range(0, 5).forEach((i) => {
alertsClient?.report({
id: `instance-${i}`,
actionGroup: 'default',
payload: { original_source: { _id: `instance-${i}` } },
});
});
return { state: {} };
},
autoRecoverAlerts: false,
alerts: {
context: 'test.dangerouslycreatealertsinallspaces',
mappings: { dynamic: false, fieldMap: { ...alertFieldMap } },
shouldWrite: true,
isSpaceAware: false,
dangerouslyCreateAlertsInAllSpaces: true,
},
};
const noopRuleType: RuleType<{}, {}, {}, {}, {}, 'default'> = {
id: 'test.noop',
name: 'Test: Noop',
@ -1567,4 +1676,6 @@ export function defineRuleTypes(
alerting.registerType(getWaitingRuleType(logger));
alerting.registerType(getSeverityRuleType());
alerting.registerType(getInternalRuleType());
alerting.registerType(dangerouslyCreateAlertsInAllSpacesPersistenceRuleType);
alerting.registerType(dangerouslyCreateAlertsInAllSpacesRuleType);
}

View file

@ -25,6 +25,7 @@
"@kbn/core-saved-objects-server",
"@kbn/logging",
"@kbn/alerting-api-integration-helpers",
"@kbn/alerts-as-data-utils",
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,122 @@
/*
* 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 type { Alert } from '@kbn/alerts-as-data-utils';
import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
import { SPACE_IDS } from '@kbn/rule-data-utils';
import { getEventLog, ObjectRemover, getTestRuleData } from '../../../../common/lib';
import type { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { Spaces } from '../../../scenarios';
// eslint-disable-next-line import/no-default-export
export default function dangerouslyCreateAlertsInAllSpacesTests({
getService,
}: FtrProviderContext) {
const es = getService('es');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const retry = getService('retry');
const alertsAsDataIndex = '.alerts-test.dangerouslycreatealertsinallspaces.alerts-default';
describe('dangerouslyCreateAlertsInAllSpaces', () => {
const objectRemover = new ObjectRemover(supertestWithoutAuth);
afterEach(async () => {
await objectRemover.removeAll();
await es.deleteByQuery({
index: alertsAsDataIndex,
query: { match_all: {} },
conflicts: 'proceed',
});
});
it('creates alerts with space_id "*" for persistence rule type with dangerouslyCreateAlertsInAllSpaces enabled', async () => {
const createdRule = await supertestWithoutAuth
.post(`/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.persistenceDangerouslyCreateAlertsInAllSpaces',
schedule: { interval: '1d' },
throttle: null,
params: {},
actions: [],
})
);
expect(createdRule.status).to.eql(200);
const ruleId = createdRule.body.id;
objectRemover.add(Spaces.default.id, ruleId, 'rule', 'alerting');
// Wait for the event log execute doc so we can get the execution UUID
await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 1 }]]));
// Query for alerts
const alertDocs = await queryForAlertDocs<Alert>();
for (let i = 0; i < alertDocs.length; ++i) {
const source: Alert = alertDocs[i]._source!;
expect(source[SPACE_IDS]).to.eql(['*']);
}
});
it('creates alerts with space_id "*" for alerting framework rule type with dangerouslyCreateAlertsInAllSpaces enabled', async () => {
const createdRule = await supertestWithoutAuth
.post(`/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.dangerouslyCreateAlertsInAllSpaces',
schedule: { interval: '1d' },
throttle: null,
params: {},
actions: [],
})
);
expect(createdRule.status).to.eql(200);
const ruleId = createdRule.body.id;
objectRemover.add(Spaces.default.id, ruleId, 'rule', 'alerting');
// Wait for the event log execute doc so we can get the execution UUID
await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 1 }]]));
// Query for alerts
const alertDocs = await queryForAlertDocs<Alert>();
for (let i = 0; i < alertDocs.length; ++i) {
const source: Alert = alertDocs[i]._source!;
expect(source[SPACE_IDS]).to.eql(['*']);
}
});
});
async function queryForAlertDocs<T>(): Promise<Array<SearchHit<T>>> {
const searchResult = await es.search({
index: alertsAsDataIndex,
query: { match_all: {} },
});
return searchResult.hits.hits as Array<SearchHit<T>>;
}
async function waitForEventLogDocs(
id: string,
actions: Map<string, { gte: number } | { equal: number }>
) {
return await retry.try(async () => {
return await getEventLog({
getService,
spaceId: Spaces.default.id,
type: 'alert',
id,
provider: 'alerting',
actions,
});
});
}
}

View file

@ -18,5 +18,6 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./builtin_alert_types'));
loadTestFile(require.resolve('./maintenance_window_flows'));
loadTestFile(require.resolve('./maintenance_window_scoped_query'));
loadTestFile(require.resolve('./dangerously_create_alerts_in_all_spaces'));
});
}

View file

@ -24,7 +24,7 @@ export const createRule = async ({
objectRemover,
overwrites,
}: {
actionId: string;
actionId?: string;
pattern?: { instance: boolean[] };
supertest: SuperTestAgent;
objectRemover: ObjectRemover;
@ -43,18 +43,20 @@ export const createRule = async ({
params: {
pattern,
},
actions: [
{
id: actionId,
group: 'default',
params: {},
},
{
id: actionId,
group: 'recovered',
params: {},
},
],
actions: actionId
? [
{
id: actionId,
group: 'default',
params: {},
},
{
id: actionId,
group: 'recovered',
params: {},
},
]
: [],
...overwrites,
})
)

View file

@ -34,6 +34,11 @@ jest.mock('../utils/utils', () => ({
checkForFrozenIndices: jest.fn(async () => []),
}));
jest.mock('@kbn/alerting-plugin/server', () => ({
...jest.requireActual('@kbn/alerting-plugin/server'),
shouldCreateAlertsInAllSpaces: jest.fn().mockReturnValue(false),
}));
jest.mock('../utils/get_list_client', () => ({
getListClient: jest.fn().mockReturnValue({
listClient: jest.fn(),