diff --git a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts index 8eaef033ad1f35..569e8b81afd2dc 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts @@ -124,6 +124,7 @@ describe('checking migration metadata changes on all registered SO types', () => "osquery-pack-asset": "de8783298eb33a577bf1fa0caacd42121dcfae91", "osquery-saved-query": "7b213b4b7a3e59350e99c50e8df9948662ed493a", "query": "4640ef356321500a678869f24117b7091a911cb6", + "rules-settings": "1af4c9abd4b40a154e233c2af4867df7aab7ac24", "sample-data-telemetry": "8b10336d9efae6f3d5593c4cc89fb4abcdf84e04", "search": "c48f5ab5d94545780ea98de1bff9e39f17f3606b", "search-session": "ba383309da68a15be3765977f7a44c84f0ec7964", diff --git a/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts index 2cb85526f93408..2c8dbabf878a1e 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/type_registrations.test.ts @@ -92,6 +92,7 @@ const previouslyRegisteredTypes = [ 'osquery-usage-metric', 'osquery-manager-usage-metric', 'query', + 'rules-settings', 'sample-data-telemetry', 'search', 'search-session', diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index 795a05dcb802c2..9a977213d44465 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -11,6 +11,7 @@ import { AlertsHealth } from './rule'; export * from './rule'; +export * from './rules_settings'; export * from './rule_type'; export * from './rule_task_instance'; export * from './rule_navigation'; diff --git a/x-pack/plugins/alerting/common/rules_settings.ts b/x-pack/plugins/alerting/common/rules_settings.ts new file mode 100644 index 00000000000000..755becc8a9822d --- /dev/null +++ b/x-pack/plugins/alerting/common/rules_settings.ts @@ -0,0 +1,51 @@ +/* + * 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 interface RulesSettingsModificationMetadata { + createdBy: string | null; + updatedBy: string | null; + createdAt: string; + updatedAt: string; +} + +export interface RulesSettingsFlappingProperties { + enabled: boolean; + lookBackWindow: number; + statusChangeThreshold: number; +} + +export type RulesSettingsFlapping = RulesSettingsFlappingProperties & + RulesSettingsModificationMetadata; + +export interface RulesSettings { + flapping: RulesSettingsFlapping; +} + +export const MIN_LOOK_BACK_WINDOW = 2; +export const MAX_LOOK_BACK_WINDOW = 20; +export const MIN_STATUS_CHANGE_THRESHOLD = 2; +export const MAX_STATUS_CHANGE_THRESHOLD = 20; + +export const RULES_SETTINGS_FEATURE_ID = 'rulesSettings'; +export const ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID = 'allFlappingSettings'; +export const READ_FLAPPING_SETTINGS_SUB_FEATURE_ID = 'readFlappingSettings'; + +export const API_PRIVILEGES = { + READ_FLAPPING_SETTINGS: 'read-flapping-settings', + WRITE_FLAPPING_SETTINGS: 'write-flapping-settings', +}; + +export const RULES_SETTINGS_SAVED_OBJECT_TYPE = 'rules-settings'; +export const RULES_SETTINGS_SAVED_OBJECT_ID = 'rules-settings'; + +export const DEFAULT_LOOK_BACK_WINDOW = 20; +export const DEFAULT_STATUS_CHANGE_THRESHOLD = 4; + +export const DEFAULT_FLAPPING_SETTINGS = { + enabled: true, + lookBackWindow: 20, + statusChangeThreshold: 4, +}; diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 2c057cc4cfc434..7b85bc898fee03 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -78,6 +78,7 @@ describe('Alerting Plugin', () => { statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, + features: featuresPluginMock.createSetup(), }; let plugin: AlertingPlugin; @@ -221,6 +222,7 @@ describe('Alerting Plugin', () => { statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, + features: featuresPluginMock.createSetup(), }); const startContract = plugin.start(coreMock.createStart(), { @@ -267,6 +269,7 @@ describe('Alerting Plugin', () => { statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, + features: featuresPluginMock.createSetup(), }); const startContract = plugin.start(coreMock.createStart(), { @@ -324,6 +327,7 @@ describe('Alerting Plugin', () => { statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, + features: featuresPluginMock.createSetup(), }); const startContract = plugin.start(coreMock.createStart(), { diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 13f39504d27815..86d8a7411c16d2 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -47,13 +47,17 @@ import { IEventLogService, IEventLogClientService, } from '@kbn/event-log-plugin/server'; -import { PluginStartContract as FeaturesPluginStart } from '@kbn/features-plugin/server'; +import { + PluginStartContract as FeaturesPluginStart, + PluginSetupContract as FeaturesPluginSetup, +} from '@kbn/features-plugin/server'; import { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; import { MonitoringCollectionSetup } from '@kbn/monitoring-collection-plugin/server'; import { SharePluginStart } from '@kbn/share-plugin/server'; import { RuleTypeRegistry } from './rule_type_registry'; import { TaskRunnerFactory } from './task_runner'; import { RulesClientFactory } from './rules_client_factory'; +import { RulesSettingsClientFactory } from './rules_settings_client_factory'; import { ILicenseState, LicenseState } from './lib/license_state'; import { AlertingRequestHandlerContext, ALERTS_FEATURE_ID } from './types'; import { defineRoutes } from './routes'; @@ -82,6 +86,7 @@ import { getSecurityHealth, SecurityHealth } from './lib/get_security_health'; import { registerNodeCollector, registerClusterCollector, InMemoryMetrics } from './monitoring'; import { getRuleTaskTimeout } from './lib/get_rule_task_timeout'; import { getActionsConfigMap } from './lib/get_actions_config_map'; +import { rulesSettingsFeature } from './rules_settings_feature'; export const EVENT_LOG_PROVIDER = 'alerting'; export const EVENT_LOG_ACTIONS = { @@ -146,6 +151,7 @@ export interface AlertingPluginsSetup { statusService: StatusServiceSetup; monitoringCollection: MonitoringCollectionSetup; data: DataPluginSetup; + features: FeaturesPluginSetup; } export interface AlertingPluginsStart { @@ -172,6 +178,7 @@ export class AlertingPlugin { private security?: SecurityPluginSetup; private readonly rulesClientFactory: RulesClientFactory; private readonly alertingAuthorizationClientFactory: AlertingAuthorizationClientFactory; + private readonly rulesSettingsClientFactory: RulesSettingsClientFactory; private readonly telemetryLogger: Logger; private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; private eventLogService?: IEventLogService; @@ -186,6 +193,7 @@ export class AlertingPlugin { this.taskRunnerFactory = new TaskRunnerFactory(); this.rulesClientFactory = new RulesClientFactory(); this.alertingAuthorizationClientFactory = new AlertingAuthorizationClientFactory(); + this.rulesSettingsClientFactory = new RulesSettingsClientFactory(); this.telemetryLogger = initializerContext.logger.get('usage'); this.kibanaVersion = initializerContext.env.packageInfo.version; this.inMemoryMetrics = new InMemoryMetrics(initializerContext.logger.get('in_memory_metrics')); @@ -210,6 +218,8 @@ export class AlertingPlugin { }; }); + plugins.features.registerKibanaFeature(rulesSettingsFeature); + this.isESOCanEncrypt = plugins.encryptedSavedObjects.canEncrypt; if (!this.isESOCanEncrypt) { @@ -368,6 +378,7 @@ export class AlertingPlugin { ruleTypeRegistry, rulesClientFactory, alertingAuthorizationClientFactory, + rulesSettingsClientFactory, security, licenseState, } = this; @@ -416,6 +427,12 @@ export class AlertingPlugin { minimumScheduleInterval: this.config.rules.minimumScheduleInterval, }); + rulesSettingsClientFactory.initialize({ + logger: this.logger, + savedObjectsService: core.savedObjects, + securityPluginStart: plugins.security, + }); + const getRulesClientWithRequest = (request: KibanaRequest) => { if (isESOCanEncrypt !== true) { throw new Error( @@ -483,13 +500,16 @@ export class AlertingPlugin { private createRouteHandlerContext = ( core: CoreSetup ): IContextProvider => { - const { ruleTypeRegistry, rulesClientFactory } = this; + const { ruleTypeRegistry, rulesClientFactory, rulesSettingsClientFactory } = this; return async function alertsRouteHandlerContext(context, request) { const [{ savedObjects }] = await core.getStartServices(); return { getRulesClient: () => { return rulesClientFactory!.create(request, savedObjects); }, + getRulesSettingsClient: () => { + return rulesSettingsClientFactory.createWithAuthorization(request); + }, listTypes: ruleTypeRegistry!.list.bind(ruleTypeRegistry!), getFrameworkHealth: async () => await getHealth(savedObjects.createInternalRepository(['alert'])), diff --git a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts index b5fbaa3d0cf863..0df0f371f62b6d 100644 --- a/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts +++ b/x-pack/plugins/alerting/server/routes/_mock_handler_arguments.ts @@ -10,17 +10,20 @@ import { identity } from 'lodash'; import type { MethodKeysOf } from '@kbn/utility-types'; import { httpServerMock } from '@kbn/core/server/mocks'; import { rulesClientMock, RulesClientMock } from '../rules_client.mock'; +import { rulesSettingsClientMock, RulesSettingsClientMock } from '../rules_settings_client.mock'; import { AlertsHealth, RuleType } from '../../common'; import type { AlertingRequestHandlerContext } from '../types'; export function mockHandlerArguments( { rulesClient = rulesClientMock.create(), + rulesSettingsClient = rulesSettingsClientMock.create(), listTypes: listTypesRes = [], getFrameworkHealth, areApiKeysEnabled, }: { rulesClient?: RulesClientMock; + rulesSettingsClient?: RulesSettingsClientMock; listTypes?: RuleType[]; getFrameworkHealth?: jest.MockInstance, []> & (() => Promise); @@ -41,6 +44,9 @@ export function mockHandlerArguments( getRulesClient() { return rulesClient || rulesClientMock.create(); }, + getRulesSettingsClient() { + return rulesSettingsClient || rulesSettingsClientMock.create(); + }, getFrameworkHealth, areApiKeysEnabled: areApiKeysEnabled ? areApiKeysEnabled : () => Promise.resolve(true), }, diff --git a/x-pack/plugins/alerting/server/routes/get_flapping_settings.test.ts b/x-pack/plugins/alerting/server/routes/get_flapping_settings.test.ts new file mode 100644 index 00000000000000..156ab604fb9050 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/get_flapping_settings.test.ts @@ -0,0 +1,63 @@ +/* + * 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 { mockHandlerArguments } from './_mock_handler_arguments'; +import { rulesSettingsClientMock, RulesSettingsClientMock } from '../rules_settings_client.mock'; +import { getFlappingSettingsRoute } from './get_flapping_settings'; + +let rulesSettingsClient: RulesSettingsClientMock; + +jest.mock('../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); + rulesSettingsClient = rulesSettingsClientMock.create(); +}); + +describe('getFlappingSettingsRoute', () => { + test('gets flapping settings', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getFlappingSettingsRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config).toMatchInlineSnapshot(` + Object { + "options": Object { + "tags": Array [ + "access:read-flapping-settings", + ], + }, + "path": "/internal/alerting/rules/settings/_flapping", + "validate": false, + } + `); + + (rulesSettingsClient.flapping().get as jest.Mock).mockResolvedValue({ + enabled: true, + lookBackWindow: 10, + statusChangeThreshold: 10, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const [context, req, res] = mockHandlerArguments({ rulesSettingsClient }, {}, ['ok']); + + await handler(context, req, res); + + expect(rulesSettingsClient.flapping().get).toHaveBeenCalledTimes(1); + expect(res.ok).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/get_flapping_settings.ts b/x-pack/plugins/alerting/server/routes/get_flapping_settings.ts new file mode 100644 index 00000000000000..6ae039032994d9 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/get_flapping_settings.ts @@ -0,0 +1,34 @@ +/* + * 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 { ILicenseState } from '../lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types'; +import { verifyAccessAndContext } from './lib'; +import { API_PRIVILEGES } from '../../common'; + +export const getFlappingSettingsRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.get( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_flapping`, + validate: false, + options: { + tags: [`access:${API_PRIVILEGES.READ_FLAPPING_SETTINGS}`], + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesSettingsClient = (await context.alerting).getRulesSettingsClient(); + const flappingSettings = await rulesSettingsClient.flapping().get(); + return res.ok({ body: flappingSettings }); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index c4c62a92cbedeb..32deff30edd7cf 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -42,6 +42,8 @@ import { bulkDeleteRulesRoute } from './bulk_delete_rules'; import { bulkEnableRulesRoute } from './bulk_enable_rules'; import { bulkDisableRulesRoute } from './bulk_disable_rules'; import { cloneRuleRoute } from './clone_rule'; +import { getFlappingSettingsRoute } from './get_flapping_settings'; +import { updateFlappingSettingsRoute } from './update_flapping_settings'; export interface RouteOptions { router: IRouter; @@ -87,4 +89,6 @@ export function defineRoutes(opts: RouteOptions) { unsnoozeRuleRoute(router, licenseState); runSoonRoute(router, licenseState); cloneRuleRoute(router, licenseState); + getFlappingSettingsRoute(router, licenseState); + updateFlappingSettingsRoute(router, licenseState); } diff --git a/x-pack/plugins/alerting/server/routes/update_flapping_settings.test.ts b/x-pack/plugins/alerting/server/routes/update_flapping_settings.test.ts new file mode 100644 index 00000000000000..28914e71e7dd3e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/update_flapping_settings.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { mockHandlerArguments } from './_mock_handler_arguments'; +import { rulesSettingsClientMock, RulesSettingsClientMock } from '../rules_settings_client.mock'; +import { updateFlappingSettingsRoute } from './update_flapping_settings'; + +let rulesSettingsClient: RulesSettingsClientMock; + +jest.mock('../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); + rulesSettingsClient = rulesSettingsClientMock.create(); +}); + +describe('updateFlappingSettingsRoute', () => { + test('updates flapping settings', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + updateFlappingSettingsRoute(router, licenseState); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/alerting/rules/settings/_flapping"`); + expect(config.options).toMatchInlineSnapshot(` + Object { + "tags": Array [ + "access:write-flapping-settings", + ], + } + `); + + (rulesSettingsClient.flapping().get as jest.Mock).mockResolvedValue({ + enabled: true, + lookBackWindow: 10, + statusChangeThreshold: 10, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + const updateResult = { + enabled: false, + lookBackWindow: 6, + statusChangeThreshold: 5, + }; + + const [context, req, res] = mockHandlerArguments( + { rulesSettingsClient }, + { + body: updateResult, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(rulesSettingsClient.flapping().update).toHaveBeenCalledTimes(1); + expect((rulesSettingsClient.flapping().update as jest.Mock).mock.calls[0]) + .toMatchInlineSnapshot(` + Array [ + Object { + "enabled": false, + "lookBackWindow": 6, + "statusChangeThreshold": 5, + }, + ] + `); + expect(res.ok).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/update_flapping_settings.ts b/x-pack/plugins/alerting/server/routes/update_flapping_settings.ts new file mode 100644 index 00000000000000..ede33a7d36a95d --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/update_flapping_settings.ts @@ -0,0 +1,47 @@ +/* + * 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 { ILicenseState } from '../lib'; +import { verifyAccessAndContext } from './lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types'; +import { API_PRIVILEGES } from '../../common'; + +const bodySchema = schema.object({ + enabled: schema.boolean(), + lookBackWindow: schema.number(), + statusChangeThreshold: schema.number(), +}); + +export const updateFlappingSettingsRoute = ( + router: IRouter, + licenseState: ILicenseState +) => { + router.post( + { + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_flapping`, + validate: { + body: bodySchema, + }, + options: { + tags: [`access:${API_PRIVILEGES.WRITE_FLAPPING_SETTINGS}`], + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesSettingsClient = (await context.alerting).getRulesSettingsClient(); + + const updatedFlappingSettings = await rulesSettingsClient.flapping().update(req.body); + + return res.ok({ + body: updatedFlappingSettings, + }); + }) + ) + ); +}; diff --git a/x-pack/plugins/alerting/server/rules_settings_client.mock.ts b/x-pack/plugins/alerting/server/rules_settings_client.mock.ts new file mode 100644 index 00000000000000..2c321e54ebf71d --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client.mock.ts @@ -0,0 +1,32 @@ +/* + * 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 { RulesSettingsClientApi, RulesSettingsFlappingClientApi } from './types'; + +export type RulesSettingsClientMock = jest.Mocked; +export type RulesSettingsFlappingClientMock = jest.Mocked; + +// Warning: Becareful when resetting all mocks in tests as it would clear +// the mock return value on the flapping +const createRulesSettingsClientMock = () => { + const flappingMocked: RulesSettingsFlappingClientMock = { + get: jest.fn(), + update: jest.fn(), + }; + const mocked: RulesSettingsClientMock = { + get: jest.fn(), + create: jest.fn(), + flapping: jest.fn().mockReturnValue(flappingMocked), + }; + return mocked; +}; + +export const rulesSettingsClientMock: { + create: () => RulesSettingsClientMock; +} = { + create: createRulesSettingsClientMock, +}; diff --git a/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.test.ts b/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.test.ts new file mode 100644 index 00000000000000..ca69100fcfaedb --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.test.ts @@ -0,0 +1,185 @@ +/* + * 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 { + RulesSettingsFlappingClient, + RulesSettingsFlappingClientConstructorOptions, +} from './rules_settings_flapping_client'; +import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { + RULES_SETTINGS_FEATURE_ID, + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID, + DEFAULT_FLAPPING_SETTINGS, + RulesSettings, +} from '../../../common'; + +const mockDateString = '2019-02-12T21:01:22.479Z'; + +const savedObjectsClient = savedObjectsClientMock.create(); + +const getMockRulesSettings = (): RulesSettings => { + return { + flapping: { + enabled: DEFAULT_FLAPPING_SETTINGS.enabled, + lookBackWindow: DEFAULT_FLAPPING_SETTINGS.lookBackWindow, + statusChangeThreshold: DEFAULT_FLAPPING_SETTINGS.statusChangeThreshold, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }; +}; + +const rulesSettingsFlappingClientParams: jest.Mocked = + { + logger: loggingSystemMock.create().get(), + getOrCreate: jest.fn().mockReturnValue({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: getMockRulesSettings(), + references: [], + version: '123', + }), + getModificationMetadata: jest.fn(), + savedObjectsClient, + }; + +describe('RulesSettingsFlappingClient', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(mockDateString)); + }); + + afterAll(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + test('can get flapping settings', async () => { + const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams); + const result = await client.get(); + + expect(result).toEqual( + expect.objectContaining({ + enabled: DEFAULT_FLAPPING_SETTINGS.enabled, + lookBackWindow: DEFAULT_FLAPPING_SETTINGS.lookBackWindow, + statusChangeThreshold: DEFAULT_FLAPPING_SETTINGS.statusChangeThreshold, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }) + ); + }); + + test('can update flapping settings', async () => { + const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams); + + const mockResolve = { + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: getMockRulesSettings(), + references: [], + version: '123', + }; + + savedObjectsClient.update.mockResolvedValueOnce({ + ...mockResolve, + attributes: { + flapping: { + ...mockResolve.attributes.flapping, + enabled: false, + lookBackWindow: 19, + statusChangeThreshold: 3, + }, + }, + }); + + const result = await client.update({ + enabled: false, + lookBackWindow: 19, + statusChangeThreshold: 3, + }); + + expect(savedObjectsClient.update).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID, + { + flapping: expect.objectContaining({ + enabled: false, + lookBackWindow: 19, + statusChangeThreshold: 3, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { version: '123' } + ); + + expect(result).toEqual( + expect.objectContaining({ + enabled: false, + lookBackWindow: 19, + statusChangeThreshold: 3, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }) + ); + }); + + test('throws if savedObjectsClient failed to update', async () => { + const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams); + savedObjectsClient.update.mockRejectedValueOnce(new Error('failed!!')); + + await expect( + client.update({ + enabled: false, + lookBackWindow: 19, + statusChangeThreshold: 3, + }) + ).rejects.toThrowError( + 'savedObjectsClient errored trying to update flapping settings: failed!!' + ); + }); + + test('throws if new flapping setting fails verification', async () => { + const client = new RulesSettingsFlappingClient(rulesSettingsFlappingClientParams); + await expect( + client.update({ + enabled: true, + lookBackWindow: 200, + statusChangeThreshold: 500, + }) + ).rejects.toThrowError('Invalid lookBackWindow value, must be between 2 and 20, but got: 200.'); + + await expect( + client.update({ + enabled: true, + lookBackWindow: 20, + statusChangeThreshold: 500, + }) + ).rejects.toThrowError( + 'Invalid statusChangeThreshold value, must be between 2 and 20, but got: 500.' + ); + + await expect( + client.update({ + enabled: true, + lookBackWindow: 10, + statusChangeThreshold: 20, + }) + ).rejects.toThrowError( + 'Invalid values,lookBackWindow (10) must be equal to or greater than statusChangeThreshold (20).' + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.ts b/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.ts new file mode 100644 index 00000000000000..65db68aaba5255 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client/flapping/rules_settings_flapping_client.ts @@ -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 Boom from '@hapi/boom'; +import { Logger, SavedObjectsClientContract, SavedObject } from '@kbn/core/server'; +import { + RulesSettings, + RulesSettingsFlapping, + RulesSettingsFlappingProperties, + RulesSettingsModificationMetadata, + MIN_LOOK_BACK_WINDOW, + MAX_LOOK_BACK_WINDOW, + MIN_STATUS_CHANGE_THRESHOLD, + MAX_STATUS_CHANGE_THRESHOLD, + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID, +} from '../../../common'; + +const verifyFlappingSettings = (flappingSettings: RulesSettingsFlappingProperties) => { + const { lookBackWindow, statusChangeThreshold } = flappingSettings; + + if (lookBackWindow < MIN_LOOK_BACK_WINDOW || lookBackWindow > MAX_LOOK_BACK_WINDOW) { + throw Boom.badRequest( + `Invalid lookBackWindow value, must be between ${MIN_LOOK_BACK_WINDOW} and ${MAX_LOOK_BACK_WINDOW}, but got: ${lookBackWindow}.` + ); + } + + if ( + statusChangeThreshold < MIN_STATUS_CHANGE_THRESHOLD || + statusChangeThreshold > MAX_STATUS_CHANGE_THRESHOLD + ) { + throw Boom.badRequest( + `Invalid statusChangeThreshold value, must be between ${MIN_STATUS_CHANGE_THRESHOLD} and ${MAX_STATUS_CHANGE_THRESHOLD}, but got: ${statusChangeThreshold}.` + ); + } + + if (lookBackWindow < statusChangeThreshold) { + throw Boom.badRequest( + `Invalid values,lookBackWindow (${lookBackWindow}) must be equal to or greater than statusChangeThreshold (${statusChangeThreshold}).` + ); + } +}; + +export interface RulesSettingsFlappingClientConstructorOptions { + readonly logger: Logger; + readonly savedObjectsClient: SavedObjectsClientContract; + readonly getOrCreate: () => Promise>; + readonly getModificationMetadata: () => Promise; +} + +export class RulesSettingsFlappingClient { + private readonly logger: Logger; + private readonly savedObjectsClient: SavedObjectsClientContract; + private readonly getOrCreate: () => Promise>; + private readonly getModificationMetadata: () => Promise; + + constructor(options: RulesSettingsFlappingClientConstructorOptions) { + this.logger = options.logger; + this.savedObjectsClient = options.savedObjectsClient; + this.getOrCreate = options.getOrCreate; + this.getModificationMetadata = options.getModificationMetadata; + } + + public async get(): Promise { + const rulesSettings = await this.getOrCreate(); + return rulesSettings.attributes.flapping; + } + + public async update(newFlappingProperties: RulesSettingsFlappingProperties) { + try { + verifyFlappingSettings(newFlappingProperties); + } catch (e) { + this.logger.error( + `Failed to verify new flapping settings properties when updating. Error: ${e}` + ); + throw e; + } + + const { attributes, version } = await this.getOrCreate(); + const modificationMetadata = await this.getModificationMetadata(); + + try { + const result = await this.savedObjectsClient.update( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID, + { + ...attributes, + flapping: { + ...attributes.flapping, + ...newFlappingProperties, + ...modificationMetadata, + }, + }, + { + version, + } + ); + return result.attributes.flapping; + } catch (e) { + const errorMessage = 'savedObjectsClient errored trying to update flapping settings'; + this.logger.error(`${errorMessage}: ${e}`); + throw Boom.boomify(e, { message: errorMessage }); + } + } +} diff --git a/x-pack/plugins/alerting/server/rules_settings_client/index.ts b/x-pack/plugins/alerting/server/rules_settings_client/index.ts new file mode 100644 index 00000000000000..efbb3f0b3ccfe9 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client/index.ts @@ -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 * from './rules_settings_client'; +export * from './flapping/rules_settings_flapping_client'; diff --git a/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.test.ts b/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.test.ts new file mode 100644 index 00000000000000..a40c491b9117ec --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.test.ts @@ -0,0 +1,285 @@ +/* + * 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 { + RulesSettingsClient, + RulesSettingsClientConstructorOptions, +} from './rules_settings_client'; +import { RulesSettingsFlappingClient } from './flapping/rules_settings_flapping_client'; +import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { SavedObjectsErrorHelpers } from '@kbn/core/server'; +import { + RULES_SETTINGS_FEATURE_ID, + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID, + DEFAULT_FLAPPING_SETTINGS, + RulesSettings, +} from '../../common'; + +const mockDateString = '2019-02-12T21:01:22.479Z'; + +const savedObjectsClient = savedObjectsClientMock.create(); + +const rulesSettingsClientParams: jest.Mocked = { + logger: loggingSystemMock.create().get(), + getUserName: jest.fn(), + savedObjectsClient, +}; + +const getMockRulesSettings = (): RulesSettings => { + return { + flapping: { + enabled: DEFAULT_FLAPPING_SETTINGS.enabled, + lookBackWindow: DEFAULT_FLAPPING_SETTINGS.lookBackWindow, + statusChangeThreshold: DEFAULT_FLAPPING_SETTINGS.statusChangeThreshold, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }; +}; + +describe('RulesSettingsClient', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(mockDateString)); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + rulesSettingsClientParams.getUserName.mockResolvedValue('test name'); + }); + + test('can initialize correctly', async () => { + const client = new RulesSettingsClient(rulesSettingsClientParams); + expect(client.flapping()).toEqual(expect.any(RulesSettingsFlappingClient)); + }); + + test('can create a new rules settings saved object', async () => { + const client = new RulesSettingsClient(rulesSettingsClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.create.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + + const result = await client.create(); + + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + flapping: expect.objectContaining({ + enabled: mockAttributes.flapping.enabled, + lookBackWindow: mockAttributes.flapping.lookBackWindow, + statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { + id: RULES_SETTINGS_SAVED_OBJECT_ID, + overwrite: true, + } + ); + expect(result.attributes).toEqual(mockAttributes); + }); + + test('can get existing rules settings saved object', async () => { + const client = new RulesSettingsClient(rulesSettingsClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + const result = await client.get(); + expect(result.attributes).toEqual(mockAttributes); + }); + + test('throws if there is no existing saved object to get', async () => { + const client = new RulesSettingsClient(rulesSettingsClientParams); + + savedObjectsClient.get.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID + ) + ); + await expect(client.get()).rejects.toThrowError(); + }); + + test('can persist flapping settings when saved object does not exist', async () => { + const client = new RulesSettingsClient(rulesSettingsClientParams); + const mockAttributes = getMockRulesSettings(); + savedObjectsClient.get.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID + ) + ); + + savedObjectsClient.create.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + + const result = await client.flapping().get(); + + expect(savedObjectsClient.get).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID + ); + + expect(savedObjectsClient.create).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + flapping: expect.objectContaining({ + enabled: mockAttributes.flapping.enabled, + lookBackWindow: mockAttributes.flapping.lookBackWindow, + statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { + id: RULES_SETTINGS_SAVED_OBJECT_ID, + overwrite: true, + } + ); + expect(result).toEqual(mockAttributes.flapping); + }); + + test('can persist flapping settings when saved object already exists', async () => { + const client = new RulesSettingsClient(rulesSettingsClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + }); + + const result = await client.flapping().get(); + + expect(savedObjectsClient.get).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID + ); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + expect(result).toEqual(mockAttributes.flapping); + }); + + test('can update flapping settings when saved object does not exist', async () => { + const client = new RulesSettingsClient(rulesSettingsClientParams); + const mockAttributes = getMockRulesSettings(); + + savedObjectsClient.get.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID + ) + ); + + const mockResolve = { + id: RULES_SETTINGS_FEATURE_ID, + type: RULES_SETTINGS_SAVED_OBJECT_TYPE, + attributes: mockAttributes, + references: [], + version: '123', + }; + + savedObjectsClient.create.mockResolvedValueOnce(mockResolve); + savedObjectsClient.update.mockResolvedValueOnce({ + ...mockResolve, + attributes: { + flapping: { + ...mockResolve.attributes.flapping, + enabled: false, + lookBackWindow: 5, + statusChangeThreshold: 5, + }, + }, + }); + + // Try to update with new values + const result = await client.flapping().update({ + enabled: false, + lookBackWindow: 5, + statusChangeThreshold: 5, + }); + + // Tried to get first, but no results + expect(savedObjectsClient.get).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID + ); + + // So create a new entry + expect(savedObjectsClient.create).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + flapping: expect.objectContaining({ + enabled: mockAttributes.flapping.enabled, + lookBackWindow: mockAttributes.flapping.lookBackWindow, + statusChangeThreshold: mockAttributes.flapping.statusChangeThreshold, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { + id: RULES_SETTINGS_SAVED_OBJECT_ID, + overwrite: true, + } + ); + + // Try to update with version + expect(savedObjectsClient.update).toHaveBeenCalledWith( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID, + { + flapping: expect.objectContaining({ + enabled: false, + lookBackWindow: 5, + statusChangeThreshold: 5, + createdBy: 'test name', + updatedBy: 'test name', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + }, + { version: '123' } + ); + + expect(result).toEqual( + expect.objectContaining({ + enabled: false, + lookBackWindow: 5, + statusChangeThreshold: 5, + }) + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.ts b/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.ts new file mode 100644 index 00000000000000..1a99ab56442462 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client/rules_settings_client.ts @@ -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 { + Logger, + SavedObjectsClientContract, + SavedObject, + SavedObjectsErrorHelpers, +} from '@kbn/core/server'; +import { RulesSettingsFlappingClient } from './flapping/rules_settings_flapping_client'; +import { + RulesSettings, + DEFAULT_FLAPPING_SETTINGS, + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID, +} from '../../common'; + +export interface RulesSettingsClientConstructorOptions { + readonly logger: Logger; + readonly savedObjectsClient: SavedObjectsClientContract; + readonly getUserName: () => Promise; +} + +export class RulesSettingsClient { + private readonly logger: Logger; + private readonly savedObjectsClient: SavedObjectsClientContract; + private readonly getUserName: () => Promise; + private readonly _flapping: RulesSettingsFlappingClient; + + constructor(options: RulesSettingsClientConstructorOptions) { + this.logger = options.logger; + this.savedObjectsClient = options.savedObjectsClient; + this.getUserName = options.getUserName; + + this._flapping = new RulesSettingsFlappingClient({ + logger: this.logger, + savedObjectsClient: this.savedObjectsClient, + getOrCreate: this.getOrCreate.bind(this), + getModificationMetadata: this.getModificationMetadata.bind(this), + }); + } + + private async getModificationMetadata() { + const createTime = Date.now(); + const userName = await this.getUserName(); + + return { + createdBy: userName, + updatedBy: userName, + createdAt: new Date(createTime).toISOString(), + updatedAt: new Date(createTime).toISOString(), + }; + } + + public async get(): Promise> { + try { + return await this.savedObjectsClient.get( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + RULES_SETTINGS_SAVED_OBJECT_ID + ); + } catch (e) { + this.logger.error(`Failed to get rules setting for current space. Error: ${e}`); + throw e; + } + } + + public async create(): Promise> { + const modificationMetadata = await this.getModificationMetadata(); + + try { + return await this.savedObjectsClient.create( + RULES_SETTINGS_SAVED_OBJECT_TYPE, + { + flapping: { + ...DEFAULT_FLAPPING_SETTINGS, + ...modificationMetadata, + }, + }, + { + id: RULES_SETTINGS_SAVED_OBJECT_ID, + overwrite: true, + } + ); + } catch (e) { + this.logger.error(`Failed to create rules setting for current space. Error: ${e}`); + throw e; + } + } + + /** + * Helper function to ensure that a rules-settings saved object always exists. + * Enabled the creation of the saved object is done lazily during retrieval. + */ + private async getOrCreate(): Promise> { + try { + return await this.get(); + } catch (e) { + if (SavedObjectsErrorHelpers.isNotFoundError(e)) { + this.logger.info('Creating new default rules settings for current space.'); + return await this.create(); + } + this.logger.error(`Failed to persist rules setting for current space. Error: ${e}`); + throw e; + } + } + + public flapping(): RulesSettingsFlappingClient { + return this._flapping; + } +} diff --git a/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts b/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts new file mode 100644 index 00000000000000..176082ee023362 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client_factory.test.ts @@ -0,0 +1,161 @@ +/* + * 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 { Request } from '@hapi/hapi'; +import { CoreKibanaRequest } from '@kbn/core/server'; +import { + RulesSettingsClientFactory, + RulesSettingsClientFactoryOpts, +} from './rules_settings_client_factory'; +import { + savedObjectsClientMock, + savedObjectsServiceMock, + loggingSystemMock, +} from '@kbn/core/server/mocks'; +import { AuthenticatedUser } from '@kbn/security-plugin/common/model'; +import { securityMock } from '@kbn/security-plugin/server/mocks'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; +import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../common'; + +jest.mock('./rules_settings_client'); + +const savedObjectsClient = savedObjectsClientMock.create(); +const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); + +const securityPluginStart = securityMock.createStart(); + +const rulesSettingsClientFactoryParams: jest.Mocked = { + logger: loggingSystemMock.create().get(), + savedObjectsService, +}; + +const fakeRequest = { + app: {}, + headers: {}, + getBasePath: () => '', + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + getSavedObjectsClient: () => savedObjectsClient, +} as unknown as Request; + +beforeEach(() => { + jest.resetAllMocks(); +}); + +test('creates a rules settings client with proper constructor arguments when security is enabled', async () => { + const factory = new RulesSettingsClientFactory(); + factory.initialize({ + securityPluginStart, + ...rulesSettingsClientFactoryParams, + }); + const request = CoreKibanaRequest.from(fakeRequest); + + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + + factory.createWithAuthorization(request); + + expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { + includedHiddenTypes: [RULES_SETTINGS_SAVED_OBJECT_TYPE], + }); + + const { RulesSettingsClient } = jest.requireMock('./rules_settings_client'); + + expect(RulesSettingsClient).toHaveBeenCalledWith({ + logger: rulesSettingsClientFactoryParams.logger, + savedObjectsClient, + getUserName: expect.any(Function), + }); +}); + +test('creates a rules settings client with proper constructor arguments', async () => { + const factory = new RulesSettingsClientFactory(); + factory.initialize(rulesSettingsClientFactoryParams); + const request = CoreKibanaRequest.from(fakeRequest); + + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + + factory.createWithAuthorization(request); + + expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { + includedHiddenTypes: [RULES_SETTINGS_SAVED_OBJECT_TYPE], + }); + + const { RulesSettingsClient } = jest.requireMock('./rules_settings_client'); + + expect(RulesSettingsClient).toHaveBeenCalledWith({ + logger: rulesSettingsClientFactoryParams.logger, + savedObjectsClient, + getUserName: expect.any(Function), + }); +}); + +test('creates an unauthorized rules settings client', async () => { + const factory = new RulesSettingsClientFactory(); + factory.initialize({ + securityPluginStart, + ...rulesSettingsClientFactoryParams, + }); + const request = CoreKibanaRequest.from(fakeRequest); + + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + + factory.create(request); + + expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { + excludedExtensions: [SECURITY_EXTENSION_ID], + includedHiddenTypes: [RULES_SETTINGS_SAVED_OBJECT_TYPE], + }); + + const { RulesSettingsClient } = jest.requireMock('./rules_settings_client'); + + expect(RulesSettingsClient).toHaveBeenCalledWith({ + logger: rulesSettingsClientFactoryParams.logger, + savedObjectsClient, + getUserName: expect.any(Function), + }); +}); + +test('getUserName() returns null when security is disabled', async () => { + const factory = new RulesSettingsClientFactory(); + factory.initialize(rulesSettingsClientFactoryParams); + const request = CoreKibanaRequest.from(fakeRequest); + + factory.createWithAuthorization(request); + const constructorCall = + jest.requireMock('./rules_settings_client').RulesSettingsClient.mock.calls[0][0]; + + const userNameResult = await constructorCall.getUserName(); + expect(userNameResult).toEqual(null); +}); + +test('getUserName() returns a name when security is enabled', async () => { + const factory = new RulesSettingsClientFactory(); + factory.initialize({ + securityPluginStart, + ...rulesSettingsClientFactoryParams, + }); + const request = CoreKibanaRequest.from(fakeRequest); + + factory.createWithAuthorization(request); + + const constructorCall = + jest.requireMock('./rules_settings_client').RulesSettingsClient.mock.calls[0][0]; + + securityPluginStart.authc.getCurrentUser.mockReturnValueOnce({ + username: 'testname', + } as unknown as AuthenticatedUser); + const userNameResult = await constructorCall.getUserName(); + expect(userNameResult).toEqual('testname'); +}); diff --git a/x-pack/plugins/alerting/server/rules_settings_client_factory.ts b/x-pack/plugins/alerting/server/rules_settings_client_factory.ts new file mode 100644 index 00000000000000..619e498c6b9881 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_client_factory.ts @@ -0,0 +1,67 @@ +/* + * 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 { + KibanaRequest, + Logger, + SavedObjectsServiceStart, + SECURITY_EXTENSION_ID, +} from '@kbn/core/server'; +import { SecurityPluginStart } from '@kbn/security-plugin/server'; +import { RulesSettingsClient } from './rules_settings_client'; +import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../common'; + +export interface RulesSettingsClientFactoryOpts { + logger: Logger; + savedObjectsService: SavedObjectsServiceStart; + securityPluginStart?: SecurityPluginStart; +} + +export class RulesSettingsClientFactory { + private isInitialized = false; + private logger!: Logger; + private savedObjectsService!: SavedObjectsServiceStart; + private securityPluginStart?: SecurityPluginStart; + + public initialize(options: RulesSettingsClientFactoryOpts) { + if (this.isInitialized) { + throw new Error('RulesSettingsClientFactory already initialized'); + } + this.isInitialized = true; + this.logger = options.logger; + this.savedObjectsService = options.savedObjectsService; + this.securityPluginStart = options.securityPluginStart; + } + + private createRulesSettingsClient(request: KibanaRequest, withAuth: boolean) { + const { securityPluginStart } = this; + const savedObjectsClient = this.savedObjectsService.getScopedClient(request, { + includedHiddenTypes: [RULES_SETTINGS_SAVED_OBJECT_TYPE], + ...(withAuth ? {} : { excludedExtensions: [SECURITY_EXTENSION_ID] }), + }); + + return new RulesSettingsClient({ + logger: this.logger, + savedObjectsClient, + async getUserName() { + if (!securityPluginStart || !request) { + return null; + } + const user = securityPluginStart.authc.getCurrentUser(request); + return user ? user.username : null; + }, + }); + } + + public createWithAuthorization(request: KibanaRequest) { + return this.createRulesSettingsClient(request, true); + } + + public create(request: KibanaRequest) { + return this.createRulesSettingsClient(request, false); + } +} diff --git a/x-pack/plugins/alerting/server/rules_settings_feature.ts b/x-pack/plugins/alerting/server/rules_settings_feature.ts new file mode 100644 index 00000000000000..c207d337a2b204 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_settings_feature.ts @@ -0,0 +1,91 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { KibanaFeatureConfig } from '@kbn/features-plugin/common'; +import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; +import { + RULES_SETTINGS_FEATURE_ID, + READ_FLAPPING_SETTINGS_SUB_FEATURE_ID, + ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID, + API_PRIVILEGES, + RULES_SETTINGS_SAVED_OBJECT_TYPE, +} from '../common'; + +export const rulesSettingsFeature: KibanaFeatureConfig = { + id: RULES_SETTINGS_FEATURE_ID, + name: i18n.translate('xpack.alerting.feature.rulesSettingsFeatureName', { + defaultMessage: 'Rules Settings', + }), + category: DEFAULT_APP_CATEGORIES.management, + app: [], + management: { + insightsAndAlerting: ['triggersActions'], + }, + privileges: { + all: { + app: [], + api: [], + management: { + insightsAndAlerting: ['triggersActions'], + }, + savedObject: { + all: [RULES_SETTINGS_SAVED_OBJECT_TYPE], + read: [], + }, + ui: ['show', 'save'], + }, + read: { + app: [], + api: [], + management: { + insightsAndAlerting: ['triggersActions'], + }, + savedObject: { + all: [], + read: [RULES_SETTINGS_SAVED_OBJECT_TYPE], + }, + ui: ['show'], + }, + }, + subFeatures: [ + { + name: i18n.translate('xpack.alerting.feature.flappingSettingsSubFeatureName', { + defaultMessage: 'Flapping Detection', + }), + privilegeGroups: [ + { + groupType: 'mutually_exclusive', + privileges: [ + { + api: [API_PRIVILEGES.READ_FLAPPING_SETTINGS, API_PRIVILEGES.WRITE_FLAPPING_SETTINGS], + name: 'All', + id: ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID, + includeIn: 'all', + savedObject: { + all: [RULES_SETTINGS_SAVED_OBJECT_TYPE], + read: [], + }, + ui: ['writeFlappingSettingsUI', 'readFlappingSettingsUI'], + }, + { + api: [API_PRIVILEGES.READ_FLAPPING_SETTINGS], + name: 'Read', + id: READ_FLAPPING_SETTINGS_SUB_FEATURE_ID, + includeIn: 'read', + savedObject: { + all: [], + read: [RULES_SETTINGS_SAVED_OBJECT_TYPE], + }, + ui: ['readFlappingSettingsUI'], + }, + ], + }, + ], + }, + ], +}; diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index d8d53f4978d55a..cd69efaf3e875f 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -14,6 +14,7 @@ import type { import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; import { alertMappings } from './mappings'; +import { rulesSettingsMappings } from './rules_settings_mappings'; import { getMigrations } from './migrations'; import { transformRulesForExport } from './transform_rule_for_export'; import { RawRule } from '../types'; @@ -21,6 +22,7 @@ import { getImportWarnings } from './get_import_warnings'; import { isRuleExportable } from './is_rule_exportable'; import { RuleTypeRegistry } from '../rule_type_registry'; export { partiallyUpdateAlert } from './partially_update_alert'; +import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../../common'; // Use caution when removing items from this array! Any field which has // ever existed in the rule SO must be included in this array to prevent @@ -114,6 +116,13 @@ export function setupSavedObjects( }, }); + savedObjects.registerType({ + name: RULES_SETTINGS_SAVED_OBJECT_TYPE, + hidden: true, + namespaceType: 'single', + mappings: rulesSettingsMappings, + }); + // Encrypted attributes encryptedSavedObjects.registerType({ type: 'alert', diff --git a/x-pack/plugins/alerting/server/saved_objects/rules_settings_mappings.ts b/x-pack/plugins/alerting/server/saved_objects/rules_settings_mappings.ts new file mode 100644 index 00000000000000..d20567edc28328 --- /dev/null +++ b/x-pack/plugins/alerting/server/saved_objects/rules_settings_mappings.ts @@ -0,0 +1,45 @@ +/* + * 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 { SavedObjectsTypeMappingDefinition } from '@kbn/core/server'; + +export const rulesSettingsMappings: SavedObjectsTypeMappingDefinition = { + properties: { + flapping: { + properties: { + enabled: { + type: 'boolean', + index: false, + }, + lookBackWindow: { + type: 'long', + index: false, + }, + statusChangeThreshold: { + type: 'long', + index: false, + }, + createdBy: { + type: 'keyword', + index: false, + }, + updatedBy: { + type: 'keyword', + index: false, + }, + createdAt: { + type: 'date', + index: false, + }, + updatedAt: { + type: 'date', + index: false, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index c0399779a62df6..f2a368c062d051 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -25,6 +25,7 @@ import { SharePluginStart } from '@kbn/share-plugin/server'; import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { RulesClient } from './rules_client'; +import { RulesSettingsClient, RulesSettingsFlappingClient } from './rules_settings_client'; export * from '../common'; import { Rule, @@ -57,6 +58,7 @@ export type { RuleTypeParams }; */ export interface AlertingApiRequestHandlerContext { getRulesClient: () => RulesClient; + getRulesSettingsClient: () => RulesSettingsClient; listTypes: RuleTypeRegistry['list']; getFrameworkHealth: () => Promise; areApiKeysEnabled: () => Promise; @@ -320,6 +322,9 @@ export type RuleTypeRegistry = PublicMethodsOf; export type RulesClientApi = PublicMethodsOf; +export type RulesSettingsClientApi = PublicMethodsOf; +export type RulesSettingsFlappingClientApi = PublicMethodsOf; + export interface PublicMetricsSetters { setLastRunMetricsTotalSearchDurationMs: (totalSearchDurationMs: number) => void; setLastRunMetricsTotalIndexingDurationMs: (totalIndexingDurationMs: number) => void; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_flapping_form_section.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_flapping_form_section.tsx new file mode 100644 index 00000000000000..bfde62591f6266 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_flapping_form_section.tsx @@ -0,0 +1,213 @@ +/* + * 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 React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFormRowProps, + EuiIconTip, + EuiRange, + EuiRangeProps, + EuiSpacer, + EuiTitle, + EuiText, + EuiPanel, +} from '@elastic/eui'; +import { + RulesSettingsFlappingProperties, + MIN_LOOK_BACK_WINDOW, + MIN_STATUS_CHANGE_THRESHOLD, + MAX_LOOK_BACK_WINDOW, + MAX_STATUS_CHANGE_THRESHOLD, +} from '@kbn/alerting-plugin/common'; +import { useKibana } from '../../../common/lib/kibana'; + +type OnChangeKey = keyof Omit; + +const lookBackWindowLabel = i18n.translate( + 'xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabel', + { + defaultMessage: 'Rule run look back window', + } +); + +const statusChangeThresholdLabel = i18n.translate( + 'xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdLabel', + { + defaultMessage: 'Alert status change threshold', + } +); + +const getLookBackWindowLabelRuleRuns = (amount: number) => { + return i18n.translate( + 'xpack.triggersActionsUI.rulesSettings.flapping.lookBackWindowLabelRuleRuns', + { + defaultMessage: '{amount, number} rule {amount, plural, one {run} other {runs}}', + values: { amount }, + } + ); +}; + +const getStatusChangeThresholdRuleRuns = (amount: number) => { + return i18n.translate( + 'xpack.triggersActionsUI.rulesSettings.flapping.statusChangeThresholdTimes', + { + defaultMessage: '{amount, number} {amount, plural, one {time} other {times}}', + values: { amount }, + } + ); +}; + +export interface RulesSettingsRangeProps { + label: EuiFormRowProps['label']; + labelPopoverText?: string; + min: number; + max: number; + value: number; + disabled?: EuiRangeProps['disabled']; + onChange?: EuiRangeProps['onChange']; +} + +export const RulesSettingsFlappingTitle = () => { + return ( + +
+ +
+
+ ); +}; + +export const RulesSettingsFlappingDescription = () => { + return ( + + + + ); +}; + +export const RulesSettingsRange = memo((props: RulesSettingsRangeProps) => { + const { label, labelPopoverText, min, max, value, disabled, onChange, ...rest } = props; + + const renderLabel = () => { + return ( +
+ {label} +   + +
+ ); + }; + + return ( + + + + ); +}); + +export interface RulesSettingsFlappingFormSectionProps { + flappingSettings: RulesSettingsFlappingProperties; + compressed?: boolean; + onChange: (key: OnChangeKey, value: number) => void; +} + +export const RulesSettingsFlappingFormSection = memo( + (props: RulesSettingsFlappingFormSectionProps) => { + const { flappingSettings, compressed = false, onChange } = props; + + const { lookBackWindow, statusChangeThreshold } = flappingSettings; + + const { + application: { capabilities }, + } = useKibana().services; + + const { + rulesSettings: { writeFlappingSettingsUI }, + } = capabilities; + + const canWriteFlappingSettings = writeFlappingSettingsUI; + + return ( + + {compressed && ( + <> + + + + + + + + + + + + + )} + + onChange('lookBackWindow', parseInt(e.currentTarget.value, 10))} + label={lookBackWindowLabel} + disabled={!canWriteFlappingSettings} + /> + + + onChange('statusChangeThreshold', parseInt(e.currentTarget.value, 10))} + label={statusChangeThresholdLabel} + disabled={!canWriteFlappingSettings} + /> + + + + + {getLookBackWindowLabelRuleRuns(lookBackWindow)}, + statusChangeThreshold: ( + {getStatusChangeThresholdRuleRuns(statusChangeThreshold)} + ), + }} + /> + + + + + ); + } +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_link.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_link.test.tsx new file mode 100644 index 00000000000000..e2e454e644ac00 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_link.test.tsx @@ -0,0 +1,150 @@ +/* + * 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 React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { coreMock } from '@kbn/core/public/mocks'; +import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common'; +import { RulesSettingsLink } from './rules_settings_link'; +import { useKibana } from '../../../common/lib/kibana'; +import { getFlappingSettings } from '../../lib/rule_api'; +import { updateFlappingSettings } from '../../lib/rule_api'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('../../lib/rule_api/get_flapping_settings', () => ({ + getFlappingSettings: jest.fn(), +})); +jest.mock('../../lib/rule_api/update_flapping_settings', () => ({ + updateFlappingSettings: jest.fn(), +})); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +const useKibanaMock = useKibana as jest.Mocked; + +const mocks = coreMock.createSetup(); + +const getFlappingSettingsMock = getFlappingSettings as unknown as jest.MockedFunction< + typeof getFlappingSettings +>; +const updateFlappingSettingsMock = updateFlappingSettings as unknown as jest.MockedFunction< + typeof updateFlappingSettings +>; + +const mockFlappingSetting: RulesSettingsFlapping = { + enabled: true, + lookBackWindow: 10, + statusChangeThreshold: 11, + createdBy: 'test user', + updatedBy: 'test user', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +const RulesSettingsLinkWithProviders: React.FunctionComponent<{}> = () => ( + + + + + +); + +describe('rules_settings_link', () => { + beforeEach(async () => { + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + useKibanaMock().services.application.capabilities = { + ...capabilities, + rulesSettings: { + save: true, + show: true, + writeFlappingSettingsUI: true, + readFlappingSettingsUI: true, + }, + }; + getFlappingSettingsMock.mockResolvedValue(mockFlappingSetting); + updateFlappingSettingsMock.mockResolvedValue(mockFlappingSetting); + }); + + afterEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + cleanup(); + }); + + test('renders the rules setting link correctly', async () => { + const result = render(); + await waitFor(() => { + expect(result.getByText('Settings')).toBeInTheDocument(); + }); + expect(result.getByText('Settings')).not.toBeDisabled(); + expect(result.queryByTestId('rulesSettingsModal')).toBe(null); + }); + + test('clicking the settings link opens the rules settings modal', async () => { + const result = render(); + await waitFor(() => { + expect(result.queryByTestId('rulesSettingsModal')).toBe(null); + }); + + userEvent.click(result.getByText('Settings')); + + await waitFor(() => { + expect(result.queryByTestId('rulesSettingsModal')).not.toBe(null); + }); + }); + + test('link is hidden when provided with insufficient read permissions', async () => { + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + useKibanaMock().services.application.capabilities = { + ...capabilities, + rulesSettings: { + save: true, + show: false, + writeFlappingSettingsUI: true, + readFlappingSettingsUI: true, + }, + }; + + let result = render(); + await waitFor(() => { + expect(result.queryByTestId('rulesSettingsLink')).toBe(null); + }); + + useKibanaMock().services.application.capabilities = { + ...capabilities, + rulesSettings: { + save: true, + show: true, + writeFlappingSettingsUI: true, + readFlappingSettingsUI: false, + }, + }; + + result = render(); + await waitFor(() => { + expect(result.queryByTestId('rulesSettingsLink')).toBe(null); + }); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_link.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_link.tsx new file mode 100644 index 00000000000000..bb72db2bc41639 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_link.tsx @@ -0,0 +1,41 @@ +/* + * 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 React, { useState } from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { RulesSettingsModal } from './rules_settings_modal'; +import { useKibana } from '../../../common/lib/kibana'; + +export const RulesSettingsLink = () => { + const [isVisible, setIsVisible] = useState(false); + const { + application: { capabilities }, + } = useKibana().services; + + const { show, readFlappingSettingsUI } = capabilities.rulesSettings; + + if (!show || !readFlappingSettingsUI) { + return null; + } + + return ( + <> + setIsVisible(true)} + iconType="gear" + data-test-subj="rulesSettingsLink" + > + + + setIsVisible(false)} /> + + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.test.tsx new file mode 100644 index 00000000000000..6915a46123a407 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.test.tsx @@ -0,0 +1,264 @@ +/* + * 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 React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { render, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { coreMock } from '@kbn/core/public/mocks'; +import { IToasts } from '@kbn/core/public'; +import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common'; +import { RulesSettingsModal, RulesSettingsModalProps } from './rules_settings_modal'; +import { useKibana } from '../../../common/lib/kibana'; +import { getFlappingSettings } from '../../lib/rule_api/get_flapping_settings'; +import { updateFlappingSettings } from '../../lib/rule_api/update_flapping_settings'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('../../lib/rule_api/get_flapping_settings', () => ({ + getFlappingSettings: jest.fn(), +})); +jest.mock('../../lib/rule_api/update_flapping_settings', () => ({ + updateFlappingSettings: jest.fn(), +})); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +const useKibanaMock = useKibana as jest.Mocked; + +const mocks = coreMock.createSetup(); + +const getFlappingSettingsMock = getFlappingSettings as unknown as jest.MockedFunction< + typeof getFlappingSettings +>; +const updateFlappingSettingsMock = updateFlappingSettings as unknown as jest.MockedFunction< + typeof updateFlappingSettings +>; + +const mockFlappingSetting: RulesSettingsFlapping = { + enabled: true, + lookBackWindow: 10, + statusChangeThreshold: 10, + createdBy: 'test user', + updatedBy: 'test user', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}; + +const modalProps: RulesSettingsModalProps = { + isVisible: true, + setUpdatingRulesSettings: jest.fn(), + onClose: jest.fn(), + onSave: jest.fn(), +}; + +const RulesSettingsModalWithProviders: React.FunctionComponent = ( + props +) => ( + + + + + +); + +describe('rules_settings_modal', () => { + beforeEach(async () => { + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + useKibanaMock().services.application.capabilities = { + ...capabilities, + rulesSettings: { + save: true, + show: true, + writeFlappingSettingsUI: true, + readFlappingSettingsUI: true, + }, + }; + + useKibanaMock().services.notifications.toasts = { + addSuccess: jest.fn(), + addError: jest.fn(), + addDanger: jest.fn(), + addWarning: jest.fn(), + } as unknown as IToasts; + + getFlappingSettingsMock.mockResolvedValue(mockFlappingSetting); + updateFlappingSettingsMock.mockResolvedValue(mockFlappingSetting); + }); + + afterEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + cleanup(); + }); + + test('renders flapping settings correctly', async () => { + const result = render(); + expect(getFlappingSettingsMock).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null); + }); + expect(result.getByTestId('rulesSettingsModalEnableSwitch').getAttribute('aria-checked')).toBe( + 'true' + ); + expect(result.getByTestId('lookBackWindowRangeInput').getAttribute('value')).toBe('10'); + expect(result.getByTestId('statusChangeThresholdRangeInput').getAttribute('value')).toBe('10'); + + expect(result.getByTestId('rulesSettingsModalCancelButton')).toBeInTheDocument(); + expect(result.getByTestId('rulesSettingsModalSaveButton').getAttribute('disabled')).toBeFalsy(); + }); + + test('can save flapping settings', async () => { + const result = render(); + await waitFor(() => { + expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null); + }); + + const lookBackWindowInput = result.getByTestId('lookBackWindowRangeInput'); + const statusChangeThresholdInput = result.getByTestId('statusChangeThresholdRangeInput'); + + fireEvent.change(lookBackWindowInput, { target: { value: 20 } }); + fireEvent.change(statusChangeThresholdInput, { target: { value: 5 } }); + + expect(lookBackWindowInput.getAttribute('value')).toBe('20'); + expect(statusChangeThresholdInput.getAttribute('value')).toBe('5'); + + // Try saving + userEvent.click(result.getByTestId('rulesSettingsModalSaveButton')); + + await waitFor(() => { + expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true); + }); + expect(modalProps.onClose).toHaveBeenCalledTimes(1); + expect(updateFlappingSettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ + flappingSettings: { + enabled: true, + lookBackWindow: 20, + statusChangeThreshold: 5, + }, + }) + ); + expect(useKibanaMock().services.notifications.toasts.addSuccess).toHaveBeenCalledTimes(1); + expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true); + expect(modalProps.onSave).toHaveBeenCalledTimes(1); + }); + + test('should prevent statusChangeThreshold from being greater than lookBackWindow', async () => { + const result = render(); + await waitFor(() => { + expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null); + }); + + const lookBackWindowInput = result.getByTestId('lookBackWindowRangeInput'); + const statusChangeThresholdInput = result.getByTestId('statusChangeThresholdRangeInput'); + + // Change lookBackWindow to a smaller value + fireEvent.change(lookBackWindowInput, { target: { value: 5 } }); + // statusChangeThresholdInput gets pinned to be 5 + expect(statusChangeThresholdInput.getAttribute('value')).toBe('5'); + + // Try making statusChangeThreshold bigger + fireEvent.change(statusChangeThresholdInput, { target: { value: 20 } }); + // Still pinned + expect(statusChangeThresholdInput.getAttribute('value')).toBe('5'); + + fireEvent.change(statusChangeThresholdInput, { target: { value: 3 } }); + expect(statusChangeThresholdInput.getAttribute('value')).toBe('3'); + }); + + test('handles errors when saving settings', async () => { + updateFlappingSettingsMock.mockRejectedValue('failed!'); + + const result = render(); + await waitFor(() => { + expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null); + }); + + // Try saving + userEvent.click(result.getByTestId('rulesSettingsModalSaveButton')); + await waitFor(() => { + expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true); + }); + expect(modalProps.onClose).toHaveBeenCalledTimes(1); + expect(useKibanaMock().services.notifications.toasts.addDanger).toHaveBeenCalledTimes(1); + expect(modalProps.setUpdatingRulesSettings).toHaveBeenCalledWith(true); + expect(modalProps.onSave).toHaveBeenCalledTimes(1); + }); + + test('displays flapping detection off prompt when flapping is disabled', async () => { + const result = render(); + await waitFor(() => { + expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null); + }); + + expect(result.queryByTestId('rulesSettingsModalFlappingOffPrompt')).toBe(null); + userEvent.click(result.getByTestId('rulesSettingsModalEnableSwitch')); + expect(result.queryByTestId('rulesSettingsModalFlappingOffPrompt')).not.toBe(null); + }); + + test('form elements are disabled when provided with insufficient write permissions', async () => { + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + useKibanaMock().services.application.capabilities = { + ...capabilities, + rulesSettings: { + save: true, + show: true, + writeFlappingSettingsUI: false, + readFlappingSettingsUI: true, + }, + }; + const result = render(); + await waitFor(() => { + expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null); + }); + + expect(result.getByTestId('rulesSettingsModalEnableSwitch')).toBeDisabled(); + expect(result.getByTestId('lookBackWindowRangeInput')).toBeDisabled(); + expect(result.getByTestId('statusChangeThresholdRangeInput')).toBeDisabled(); + expect(result.getByTestId('rulesSettingsModalSaveButton')).toBeDisabled(); + }); + + test('form elements are not visible when provided with insufficient read permissions', async () => { + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); + useKibanaMock().services.application.capabilities = { + ...capabilities, + rulesSettings: { + save: true, + show: false, + writeFlappingSettingsUI: true, + readFlappingSettingsUI: false, + }, + }; + + const result = render(); + await waitFor(() => { + expect(result.queryByTestId('centerJustifiedSpinner')).toBe(null); + }); + + expect(result.getByTestId('rulesSettingsErrorPrompt')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.tsx new file mode 100644 index 00000000000000..3b9e5d9ecf0ae9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/rules_setting/rules_settings_modal.tsx @@ -0,0 +1,299 @@ +/* + * 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 React, { memo, useState } from 'react'; +import { RulesSettingsFlappingProperties } from '@kbn/alerting-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiHorizontalRule, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiModal, + EuiModalHeader, + EuiModalBody, + EuiModalFooter, + EuiModalHeaderTitle, + EuiSpacer, + EuiSwitch, + EuiSwitchProps, + EuiPanel, + EuiText, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { useKibana } from '../../../common/lib/kibana'; +import { + RulesSettingsFlappingFormSection, + RulesSettingsFlappingFormSectionProps, + RulesSettingsFlappingTitle, +} from './rules_settings_flapping_form_section'; +import { useGetFlappingSettings } from '../../hooks/use_get_flapping_settings'; +import { useUpdateFlappingSettings } from '../../hooks/use_update_flapping_settings'; +import { CenterJustifiedSpinner } from '../center_justified_spinner'; + +const flappingDescription = i18n.translate( + 'xpack.triggersActionsUI.rulesSettings.modal.flappingDetectionDescription', + { + defaultMessage: + 'Alerts that go quickly go between active and recovered are considered flapping. Detecting these changes and minimizing new alert generation can help reduce unwanted noise in your alerting system.', + } +); + +const flappingEnableLabel = i18n.translate( + 'xpack.triggersActionsUI.rulesSettings.modal.enableFlappingLabel', + { + defaultMessage: 'Enabled flapping detection (recommended)', + } +); + +export const RulesSettingsErrorPrompt = memo(() => { + return ( + + + + } + body={ +

+ +

+ } + /> + ); +}); + +interface RulesSettingsModalFormLeftProps { + settings: RulesSettingsFlappingProperties; + onChange: EuiSwitchProps['onChange']; + isSwitchDisabled: boolean; +} + +export const RulesSettingsModalFormLeft = memo((props: RulesSettingsModalFormLeftProps) => { + const { settings, onChange, isSwitchDisabled } = props; + + return ( + + + + +

{flappingDescription}

+
+
+ + + +
+
+ ); +}); + +interface RulesSettingsModalFormRightProps { + settings: RulesSettingsFlappingProperties; + onChange: RulesSettingsFlappingFormSectionProps['onChange']; +} + +export const RulesSettingsModalFormRight = memo((props: RulesSettingsModalFormRightProps) => { + const { settings, onChange } = props; + + if (!settings) { + return null; + } + if (!settings.enabled) { + return ( + + + + + + + + ); + } + + return ( + + + + ); +}); + +export interface RulesSettingsModalProps { + isVisible: boolean; + setUpdatingRulesSettings?: (isUpdating: boolean) => void; + onClose: () => void; + onSave?: () => void; +} + +export const RulesSettingsModal = memo((props: RulesSettingsModalProps) => { + const { isVisible, onClose, setUpdatingRulesSettings, onSave } = props; + + const { + application: { capabilities }, + } = useKibana().services; + const { + rulesSettings: { show, save, writeFlappingSettingsUI, readFlappingSettingsUI }, + } = capabilities; + + const [settings, setSettings] = useState(); + + const { isLoading, isError: hasError } = useGetFlappingSettings({ + enabled: isVisible, + onSuccess: (fetchedSettings) => { + if (!settings) { + setSettings({ + enabled: fetchedSettings.enabled, + lookBackWindow: fetchedSettings.lookBackWindow, + statusChangeThreshold: fetchedSettings.statusChangeThreshold, + }); + } + }, + }); + + const { mutate } = useUpdateFlappingSettings({ + onSave, + onClose, + setUpdatingRulesSettings, + }); + + // In the future when we have more settings sub-features, we should + // disassociate the rule settings capabilities (save, show) from the + // sub-feature capabilities (writeXSettingsUI). + const canWriteFlappingSettings = save && writeFlappingSettingsUI && !hasError; + const canShowFlappingSettings = show && readFlappingSettingsUI; + + const handleSettingsChange = ( + key: keyof RulesSettingsFlappingProperties, + value: number | boolean + ) => { + if (!settings) { + return; + } + + const newSettings = { + ...settings, + [key]: value, + }; + + setSettings({ + ...newSettings, + statusChangeThreshold: Math.min( + newSettings.lookBackWindow, + newSettings.statusChangeThreshold + ), + }); + }; + + const handleSave = () => { + if (!settings) { + return; + } + mutate(settings); + }; + + if (!isVisible) { + return null; + } + + const maybeRenderForm = () => { + if (hasError || !canShowFlappingSettings) { + return ; + } + if (!settings || isLoading) { + return ; + } + return ( + + + + + + + + + handleSettingsChange('enabled', e.target.checked)} + /> + handleSettingsChange(key, value)} + /> + + + ); + }; + + return ( + + + +

+ +

+
+
+ + + + {maybeRenderForm()} + + + + + + + + + + + +
+ ); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_get_flapping_settings.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_get_flapping_settings.ts new file mode 100644 index 00000000000000..23f4af9e9daa75 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_get_flapping_settings.ts @@ -0,0 +1,53 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { useQuery } from '@tanstack/react-query'; +import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common'; +import { useKibana } from '../../common/lib/kibana'; +import { getFlappingSettings } from '../lib/rule_api'; + +interface UseGetFlappingSettingsProps { + enabled: boolean; + onSuccess: (settings: RulesSettingsFlapping) => void; +} + +export const useGetFlappingSettings = (props: UseGetFlappingSettingsProps) => { + const { enabled, onSuccess } = props; + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const queryFn = () => { + return getFlappingSettings({ http }); + }; + + const onErrorFn = () => { + toasts.addDanger( + i18n.translate('xpack.triggersActionsUI.rulesSettings.modal.getRulesSettingsError', { + defaultMessage: 'Failed to get rules Settings.', + }) + ); + }; + + const { data, isFetching, isError, isLoadingError, isLoading } = useQuery({ + queryKey: ['getFlappingSettings'], + queryFn, + onError: onErrorFn, + onSuccess, + enabled, + refetchOnWindowFocus: false, + retry: false, + }); + + return { + isLoading: isLoading || isFetching, + isError: isError || isLoadingError, + data, + }; +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_update_flapping_settings.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_update_flapping_settings.ts new file mode 100644 index 00000000000000..d5f978db9d3c04 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_update_flapping_settings.ts @@ -0,0 +1,57 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { useMutation } from '@tanstack/react-query'; +import { RulesSettingsFlappingProperties } from '@kbn/alerting-plugin/common'; +import { useKibana } from '../../common/lib/kibana'; +import { updateFlappingSettings } from '../lib/rule_api'; + +interface UseUpdateFlappingSettingsProps { + onClose: () => void; + onSave?: () => void; + setUpdatingRulesSettings?: (isUpdating: boolean) => void; +} + +export const useUpdateFlappingSettings = (props: UseUpdateFlappingSettingsProps) => { + const { onSave, onClose, setUpdatingRulesSettings } = props; + + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const mutationFn = (flappingSettings: RulesSettingsFlappingProperties) => { + return updateFlappingSettings({ http, flappingSettings }); + }; + + return useMutation({ + mutationFn, + onMutate: () => { + onClose(); + setUpdatingRulesSettings?.(true); + }, + onSuccess: () => { + toasts.addSuccess( + i18n.translate('xpack.triggersActionsUI.rulesSettings.modal.updateRulesSettingsSuccess', { + defaultMessage: 'Rules settings updated successfully.', + }) + ); + }, + onError: () => { + toasts.addDanger( + i18n.translate('xpack.triggersActionsUI.rulesSettings.modal.updateRulesSettingsFailure', { + defaultMessage: 'Failed to update rules settings.', + }) + ); + }, + onSettled: () => { + setUpdatingRulesSettings?.(false); + onSave?.(); + }, + }); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/get_flapping_settings.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/get_flapping_settings.ts new file mode 100644 index 00000000000000..68947de984fb4c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/get_flapping_settings.ts @@ -0,0 +1,16 @@ +/* + * 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 { HttpSetup } from '@kbn/core/public'; +import { RulesSettingsFlapping } from '@kbn/alerting-plugin/common'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; + +export const getFlappingSettings = ({ http }: { http: HttpSetup }) => { + return http.get( + `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_flapping` + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts index f1d65768802ec4..74157ac200ce7a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/index.ts @@ -46,3 +46,5 @@ export { runSoon } from './run_soon'; export { bulkDeleteRules } from './bulk_delete'; export { bulkEnableRules } from './bulk_enable'; export { bulkDisableRules } from './bulk_disable'; +export { getFlappingSettings } from './get_flapping_settings'; +export { updateFlappingSettings } from './update_flapping_settings'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_flapping_settings.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_flapping_settings.ts new file mode 100644 index 00000000000000..f38393b591d72c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update_flapping_settings.ts @@ -0,0 +1,34 @@ +/* + * 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 { HttpSetup } from '@kbn/core/public'; +import { + RulesSettingsFlapping, + RulesSettingsFlappingProperties, +} from '@kbn/alerting-plugin/common'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../constants'; + +export const updateFlappingSettings = ({ + http, + flappingSettings, +}: { + http: HttpSetup; + flappingSettings: RulesSettingsFlappingProperties; +}) => { + let body: string; + try { + body = JSON.stringify(flappingSettings); + } catch (e) { + throw new Error(`Unable to parse flapping settings update params: ${e}`); + } + return http.post( + `${INTERNAL_BASE_ALERTING_API_PATH}/rules/settings/_flapping`, + { + body, + } + ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index d87a1d4f3a8315..360d54c6e4bf0f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -97,6 +97,7 @@ import { MULTIPLE_RULE_TITLE, } from '../translations'; import { useBulkOperationToast } from '../../../hooks/use_bulk_operation_toast'; +import { RulesSettingsLink } from '../../../components/rules_setting/rules_settings_link'; import { useRulesListUiState as useUiState } from '../../../hooks/use_rules_list_ui_state'; // Directly lazy import the flyouts because the suspendedComponentWithProps component @@ -614,11 +615,15 @@ export const RulesList = ({ if (!setHeaderActions) return; if (showHeaderWithoutCreateButton) { - setHeaderActions([]); + setHeaderActions([, ]); return; } if (showHeaderWithCreateButton) { - setHeaderActions([, ]); + setHeaderActions([ + , + , + , + ]); return; } setHeaderActions(); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/get_flapping_settings.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/get_flapping_settings.ts new file mode 100644 index 00000000000000..80e0a3e4a5986c --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/get_flapping_settings.ts @@ -0,0 +1,60 @@ +/* + * 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 { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common'; +import { UserAtSpaceScenarios } from '../../../scenarios'; +import { getUrlPrefix } from '../../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function getFlappingSettingsTests({ getService }: FtrProviderContext) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('getFlappingSettings', () => { + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle get flapping settings request appropriately', async () => { + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/internal/alerting/rules/settings/_flapping`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'space_1_all_with_restricted_fixture at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: 'Forbidden', + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body.enabled).to.eql(DEFAULT_FLAPPING_SETTINGS.enabled); + expect(response.body.lookBackWindow).to.eql(DEFAULT_FLAPPING_SETTINGS.lookBackWindow); + expect(response.body.statusChangeThreshold).to.eql( + DEFAULT_FLAPPING_SETTINGS.statusChangeThreshold + ); + expect(response.body.createdBy).to.be.a('string'); + expect(response.body.updatedBy).to.be.a('string'); + expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0); + expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts index 0dd1ec2531733e..0c6b4f815c9dcb 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/index.ts @@ -25,6 +25,8 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./bulk_enable')); loadTestFile(require.resolve('./bulk_disable')); loadTestFile(require.resolve('./clone')); + loadTestFile(require.resolve('./get_flapping_settings')); + loadTestFile(require.resolve('./update_flapping_settings')); }); }); } diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/update_flapping_settings.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/update_flapping_settings.ts new file mode 100644 index 00000000000000..29c82ee5e642ec --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/alerting/update_flapping_settings.ts @@ -0,0 +1,154 @@ +/* + * 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 { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common'; +import { UserAtSpaceScenarios, Superuser } from '../../../scenarios'; +import { getUrlPrefix } from '../../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +const resetRulesSettings = (supertestWithoutAuth: any, space: string) => { + return supertestWithoutAuth + .post(`${getUrlPrefix(space)}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth(Superuser.username, Superuser.password) + .send(DEFAULT_FLAPPING_SETTINGS); +}; + +// eslint-disable-next-line import/no-default-export +export default function updateFlappingSettingsTest({ getService }: FtrProviderContext) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('updateFlappingSettings', () => { + afterEach(async () => { + await resetRulesSettings(supertestWithoutAuth, 'space1'); + await resetRulesSettings(supertestWithoutAuth, 'space2'); + }); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle update flapping settings request appropriately', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + enabled: false, + lookBackWindow: 20, + statusChangeThreshold: 20, + }); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + case 'space_1_all_with_restricted_fixture at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: 'Forbidden', + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.statusCode).to.eql(200); + expect(response.body.enabled).to.eql(false); + expect(response.body.lookBackWindow).to.eql(20); + expect(response.body.statusChangeThreshold).to.eql(20); + expect(response.body.createdBy).to.eql(user.username); + expect(response.body.updatedBy).to.eql(user.username); + expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0); + expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + + it('should error if provided with invalid inputs', async () => { + let response = await supertestWithoutAuth + .post(`${getUrlPrefix('space1')}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth(Superuser.username, Superuser.password) + .send({ + enabled: true, + lookBackWindow: 200, + statusChangeThreshold: 200, + }) + .expect(400); + + expect(response.body.message).to.eql( + 'Invalid lookBackWindow value, must be between 2 and 20, but got: 200.' + ); + + response = await supertestWithoutAuth + .post(`${getUrlPrefix('space1')}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth(Superuser.username, Superuser.password) + .send({ + enabled: true, + lookBackWindow: 20, + statusChangeThreshold: 200, + }) + .expect(400); + + expect(response.body.message).to.eql( + 'Invalid statusChangeThreshold value, must be between 2 and 20, but got: 200.' + ); + + response = await supertestWithoutAuth + .post(`${getUrlPrefix('space1')}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth(Superuser.username, Superuser.password) + .send({ + enabled: true, + lookBackWindow: 5, + statusChangeThreshold: 10, + }) + .expect(400); + + expect(response.body.message).to.eql( + 'Invalid values,lookBackWindow (5) must be equal to or greater than statusChangeThreshold (10).' + ); + }); + + describe('updateFlappingSettings for other spaces', () => { + it('should update specific isolated settings depending on space', async () => { + // Update the rules setting in space1 + const postResponse = await supertestWithoutAuth + .post(`${getUrlPrefix('space1')}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth(Superuser.username, Superuser.password) + .send({ + enabled: false, + lookBackWindow: 20, + statusChangeThreshold: 20, + }); + + expect(postResponse.statusCode).to.eql(200); + expect(postResponse.body.enabled).to.eql(false); + expect(postResponse.body.lookBackWindow).to.eql(20); + expect(postResponse.body.statusChangeThreshold).to.eql(20); + + // Get the rules settings in space2 + const getResponse = await supertestWithoutAuth + .get(`${getUrlPrefix('space2')}/internal/alerting/rules/settings/_flapping`) + .auth(Superuser.username, Superuser.password); + + expect(getResponse.statusCode).to.eql(200); + expect(getResponse.body.enabled).to.eql(true); + expect(getResponse.body.lookBackWindow).to.eql(20); + expect(getResponse.body.statusChangeThreshold).to.eql(4); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts index bfa4968e2e4b51..9e0bc69a4175b4 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts @@ -5,6 +5,10 @@ * 2.0. */ +import { + READ_FLAPPING_SETTINGS_SUB_FEATURE_ID, + ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID, +} from '@kbn/alerting-plugin/common'; import { ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; import { Space, User } from '../common/types'; @@ -51,6 +55,7 @@ const GlobalRead: User = { alertsFixture: ['read'], alertsRestrictedFixture: ['read'], actionsSimulators: ['read'], + rulesSettings: ['read', READ_FLAPPING_SETTINGS_SUB_FEATURE_ID], }, spaces: ['*'], }, @@ -78,6 +83,7 @@ const Space1All: User = { actions: ['all'], alertsFixture: ['all'], actionsSimulators: ['all'], + rulesSettings: ['all', ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID], }, spaces: ['space1'], }, diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index ce937a5e4618ee..57012451eeb45a 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -118,6 +118,7 @@ export default function ({ getService }: FtrProviderContext) { 'logs', 'maps', 'osquery', + 'rulesSettings', 'uptime', 'siem', 'securitySolutionCases', diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 5e2c0dcc257426..ba81febadfece5 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -92,6 +92,14 @@ export default function ({ getService }: FtrProviderContext) { ], filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'], filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'], + rulesSettings: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'allFlappingSettings', + 'readFlappingSettings', + ], }, reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], }; diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 63f0b922a30e09..36cb665c838f4b 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -47,6 +47,7 @@ export default function ({ getService }: FtrProviderContext) { actions: ['all', 'read', 'minimal_all', 'minimal_read'], filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'], filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'], + rulesSettings: ['all', 'read', 'minimal_all', 'minimal_read'], }, global: ['all', 'read'], space: ['all', 'read'], @@ -161,6 +162,14 @@ export default function ({ getService }: FtrProviderContext) { 'packs_all', 'packs_read', ], + rulesSettings: [ + 'all', + 'read', + 'minimal_all', + 'minimal_read', + 'allFlappingSettings', + 'readFlappingSettings', + ], }, reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'], }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts index 00f008519f237b..bf9f30f34bb3ff 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts @@ -15,5 +15,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./details')); loadTestFile(require.resolve('./connectors')); loadTestFile(require.resolve('./logs_list')); + loadTestFile(require.resolve('./rules_settings')); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_settings.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_settings.ts new file mode 100644 index 00000000000000..6b4297f2dc1536 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/rules_settings.ts @@ -0,0 +1,139 @@ +/* + * 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 { createAlert } from '../../lib/alert_api_actions'; +import { ObjectRemover } from '../../lib/object_remover'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const supertest = getService('supertest'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header', 'security']); + const browser = getService('browser'); + const objectRemover = new ObjectRemover(supertest); + const retry = getService('retry'); + + async function refreshAlertsList() { + await retry.try(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + await testSubjects.click('triggersActions'); + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.have.length(1); + }); + } + + async function dragRangeInput( + testId: string, + steps: number = 1, + direction: 'left' | 'right' = 'right' + ) { + const inputEl = await testSubjects.find(testId); + await inputEl.focus(); + const browserKey = direction === 'left' ? browser.keys.LEFT : browser.keys.RIGHT; + while (steps--) { + await browser.pressKeys(browserKey); + } + } + + describe('rules settings modal', () => { + before(async () => { + await supertest + .post(`/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .send({ + enabled: true, + lookBackWindow: 10, + statusChangeThreshold: 10, + }) + .expect(200); + }); + + beforeEach(async () => { + await createAlert({ + supertest, + objectRemover, + }); + await refreshAlertsList(); + }); + + afterEach(async () => { + await objectRemover.removeAll(); + }); + + it('rules settings link should be enabled', async () => { + await testSubjects.existOrFail('rulesSettingsLink'); + const button = await testSubjects.find('rulesSettingsLink'); + const isDisabled = await button.getAttribute('disabled'); + expect(isDisabled).to.equal(null); + }); + + it('should allow the user to open up the rules settings modal', async () => { + await testSubjects.click('rulesSettingsLink'); + await testSubjects.existOrFail('rulesSettingsModal'); + await testSubjects.waitForDeleted('centerJustifiedSpinner'); + + // Flapping enabled by default + await testSubjects.missingOrFail('rulesSettingsModalFlappingOffPrompt'); + + await testSubjects.existOrFail('rulesSettingsModalEnableSwitch'); + await testSubjects.existOrFail('lookBackWindowRangeInput'); + await testSubjects.existOrFail('statusChangeThresholdRangeInput'); + + const lookBackWindowInput = await testSubjects.find('lookBackWindowRangeInput'); + const statusChangeThresholdInput = await testSubjects.find('statusChangeThresholdRangeInput'); + + const lookBackWindowValue = await lookBackWindowInput.getAttribute('value'); + const statusChangeThresholdValue = await statusChangeThresholdInput.getAttribute('value'); + + expect(lookBackWindowValue).to.eql('10'); + expect(statusChangeThresholdValue).to.eql('10'); + }); + + it('should allow the user to modify rules settings', async () => { + await testSubjects.click('rulesSettingsLink'); + await testSubjects.waitForDeleted('centerJustifiedSpinner'); + + await dragRangeInput('lookBackWindowRangeInput', 5, 'right'); + await dragRangeInput('statusChangeThresholdRangeInput', 5, 'left'); + + let lookBackWindowInput = await testSubjects.find('lookBackWindowRangeInput'); + let statusChangeThresholdInput = await testSubjects.find('statusChangeThresholdRangeInput'); + + let lookBackWindowValue = await lookBackWindowInput.getAttribute('value'); + let statusChangeThresholdValue = await statusChangeThresholdInput.getAttribute('value'); + + expect(lookBackWindowValue).to.eql('15'); + expect(statusChangeThresholdValue).to.eql('5'); + + await testSubjects.click('rulesSettingsModalEnableSwitch'); + await testSubjects.existOrFail('rulesSettingsModalFlappingOffPrompt'); + + // Save + await testSubjects.click('rulesSettingsModalSaveButton'); + await pageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.missingOrFail('rulesSettingsModal'); + + // Open up the modal again + await testSubjects.click('rulesSettingsLink'); + await testSubjects.waitForDeleted('centerJustifiedSpinner'); + + // Flapping initially disabled + await testSubjects.existOrFail('rulesSettingsModalFlappingOffPrompt'); + await testSubjects.click('rulesSettingsModalEnableSwitch'); + + lookBackWindowInput = await testSubjects.find('lookBackWindowRangeInput'); + statusChangeThresholdInput = await testSubjects.find('statusChangeThresholdRangeInput'); + + lookBackWindowValue = await lookBackWindowInput.getAttribute('value'); + statusChangeThresholdValue = await statusChangeThresholdInput.getAttribute('value'); + + expect(lookBackWindowValue).to.eql('15'); + expect(statusChangeThresholdValue).to.eql('5'); + }); + }); +};