diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts index 1a47ba71962537..048fb72af67beb 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts @@ -19,6 +19,7 @@ import { sortOrderSchema } from './common_schemas'; * - nested * - reverse_nested * - terms + * - multi_terms * * Not fully supported: * - filter @@ -37,7 +38,6 @@ import { sortOrderSchema } from './common_schemas'; * - global * - ip_range * - missing - * - multi_terms * - parent * - range * - rare_terms @@ -63,6 +63,36 @@ const boolSchema = s.object({ }), }); +const orderSchema = s.oneOf([ + sortOrderSchema, + s.recordOf(s.string(), sortOrderSchema), + s.arrayOf(s.recordOf(s.string(), sortOrderSchema)), +]); + +const termsSchema = s.object({ + field: s.maybe(s.string()), + collect_mode: s.maybe(s.string()), + exclude: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + include: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + execution_hint: s.maybe(s.string()), + missing: s.maybe(s.number()), + min_doc_count: s.maybe(s.number({ min: 1 })), + size: s.maybe(s.number()), + show_term_doc_count_error: s.maybe(s.boolean()), + order: s.maybe(orderSchema), +}); + +const multiTermsSchema = s.object({ + terms: s.arrayOf(termsSchema), + size: s.maybe(s.number()), + shard_size: s.maybe(s.number()), + show_term_doc_count_error: s.maybe(s.boolean()), + min_doc_count: s.maybe(s.number()), + shard_min_doc_count: s.maybe(s.number()), + collect_mode: s.maybe(s.oneOf([s.literal('depth_first'), s.literal('breadth_first')])), + order: s.maybe(s.recordOf(s.string(), orderSchema)), +}); + export const bucketAggsSchemas: Record = { date_range: s.object({ field: s.string(), @@ -104,22 +134,6 @@ export const bucketAggsSchemas: Record = { reverse_nested: s.object({ path: s.maybe(s.string()), }), - terms: s.object({ - field: s.maybe(s.string()), - collect_mode: s.maybe(s.string()), - exclude: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), - include: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), - execution_hint: s.maybe(s.string()), - missing: s.maybe(s.number()), - min_doc_count: s.maybe(s.number({ min: 1 })), - size: s.maybe(s.number()), - show_term_doc_count_error: s.maybe(s.boolean()), - order: s.maybe( - s.oneOf([ - sortOrderSchema, - s.recordOf(s.string(), sortOrderSchema), - s.arrayOf(s.recordOf(s.string(), sortOrderSchema)), - ]) - ), - }), + multi_terms: multiTermsSchema, + terms: termsSchema, }; diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts index 0296dd25b56ee6..db50ab2b45d653 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts @@ -94,6 +94,28 @@ describe('validateAndConvertAggregations', () => { }); }); + it('validates multi_terms aggregations', () => { + expect( + validateAndConvertAggregations( + ['foo'], + { + aggName: { + multi_terms: { + terms: [{ field: 'foo.attributes.description' }, { field: 'foo.attributes.bytes' }], + }, + }, + }, + mockMappings + ) + ).toEqual({ + aggName: { + multi_terms: { + terms: [{ field: 'foo.description' }, { field: 'foo.bytes' }], + }, + }, + }); + }); + it('validates a nested field in simple aggregations', () => { expect( validateAndConvertAggregations( diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.ts index 445d6b6a7ce226..76098d73306af7 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/validation.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/validation.ts @@ -8,7 +8,7 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ObjectType } from '@kbn/config-schema'; -import { isPlainObject } from 'lodash'; +import { isPlainObject, isArray } from 'lodash'; import { IndexMapping } from '../../../mappings'; import { @@ -181,11 +181,17 @@ const recursiveRewrite = ( const nestedContext = childContext(context, key); const newKey = rewriteKey ? validateAndRewriteAttributePath(key, nestedContext) : key; - const newValue = rewriteValue - ? validateAndRewriteAttributePath(value, nestedContext) - : isPlainObject(value) - ? recursiveRewrite(value, nestedContext, [...parents, key]) - : value; + + let newValue = value; + if (rewriteValue) { + newValue = validateAndRewriteAttributePath(value, nestedContext); + } else if (isArray(value)) { + newValue = value.map((v) => + isPlainObject(v) ? recursiveRewrite(v, nestedContext, parents) : v + ); + } else if (isPlainObject(value)) { + newValue = recursiveRewrite(value, nestedContext, [...parents, key]); + } return { ...memo, diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index 26aa7a706db0a2..bfa20cb2d00fe1 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -47,6 +47,7 @@ export enum WriteOperations { MuteAlert = 'muteAlert', UnmuteAlert = 'unmuteAlert', Snooze = 'snooze', + BulkEdit = 'bulkEdit', Unsnooze = 'unsnooze', } diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 1ff36f483a211a..93972dcb8df9d1 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -31,7 +31,14 @@ export type { } from './types'; export { DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT } from './config'; export type { PluginSetupContract, PluginStartContract } from './plugin'; -export type { FindResult } from './rules_client'; +export type { + FindResult, + BulkEditOperation, + BulkEditError, + BulkEditOptions, + BulkEditOptionsFilter, + BulkEditOptionsIds, +} from './rules_client'; export type { PublicAlert as Alert } from './alert'; export { parseDuration } from './lib'; export { getEsErrorMessage } from './lib/errors'; diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts new file mode 100644 index 00000000000000..34545292bf5f84 --- /dev/null +++ b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.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 { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { bulkMarkApiKeysForInvalidation } from './bulk_mark_api_keys_for_invalidation'; + +describe('bulkMarkApiKeysForInvalidation', () => { + test('should call savedObjectsClient bulkCreate with the proper params', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); + + await bulkMarkApiKeysForInvalidation( + { apiKeys: [Buffer.from('123').toString('base64'), Buffer.from('456').toString('base64')] }, + loggingSystemMock.create().get(), + unsecuredSavedObjectsClient + ); + + const bulkCreateCallMock = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]; + const savedObjects = bulkCreateCallMock[0]; + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(bulkCreateCallMock).toHaveLength(1); + + expect(savedObjects).toHaveLength(2); + expect(savedObjects[0]).toHaveProperty('type', 'api_key_pending_invalidation'); + expect(savedObjects[0]).toHaveProperty('attributes.apiKeyId', '123'); + expect(savedObjects[0]).toHaveProperty('attributes.createdAt', expect.any(String)); + expect(savedObjects[1]).toHaveProperty('type', 'api_key_pending_invalidation'); + expect(savedObjects[1]).toHaveProperty('attributes.apiKeyId', '456'); + expect(savedObjects[1]).toHaveProperty('attributes.createdAt', expect.any(String)); + }); + + test('should log the proper error when savedObjectsClient create failed', async () => { + const logger = loggingSystemMock.create().get(); + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockRejectedValueOnce(new Error('Fail')); + await bulkMarkApiKeysForInvalidation( + { apiKeys: [Buffer.from('123').toString('base64'), Buffer.from('456').toString('base64')] }, + logger, + unsecuredSavedObjectsClient + ); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to bulk mark list of API keys ["MTIz", "NDU2"] for invalidation: Fail' + ); + }); + + test('should not call savedObjectsClient bulkCreate if list of apiKeys empty', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); + + await bulkMarkApiKeysForInvalidation( + { apiKeys: [] }, + loggingSystemMock.create().get(), + unsecuredSavedObjectsClient + ); + + expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts new file mode 100644 index 00000000000000..8999d12772f035 --- /dev/null +++ b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger, SavedObjectsClientContract } from '@kbn/core/server'; + +export const bulkMarkApiKeysForInvalidation = async ( + { apiKeys }: { apiKeys: string[] }, + logger: Logger, + savedObjectsClient: SavedObjectsClientContract +): Promise => { + if (apiKeys.length === 0) { + return; + } + + try { + const apiKeyIds = apiKeys.map( + (apiKey) => Buffer.from(apiKey, 'base64').toString().split(':')[0] + ); + await savedObjectsClient.bulkCreate( + apiKeyIds.map((apiKeyId) => ({ + attributes: { + apiKeyId, + createdAt: new Date().toISOString(), + }, + type: 'api_key_pending_invalidation', + })) + ); + } catch (e) { + logger.error( + `Failed to bulk mark list of API keys [${apiKeys + .map((key) => `"${key}"`) + .join(', ')}] for invalidation: ${e.message}` + ); + } +}; diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.test.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.test.ts deleted file mode 100644 index 85ab0b579e762c..00000000000000 --- a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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 { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; -import { markApiKeyForInvalidation } from './mark_api_key_for_invalidation'; - -describe('markApiKeyForInvalidation', () => { - test('should call savedObjectsClient create with the proper params', async () => { - const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); - await markApiKeyForInvalidation( - { apiKey: Buffer.from('123:abc').toString('base64') }, - loggingSystemMock.create().get(), - unsecuredSavedObjectsClient - ); - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(2); - expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual( - 'api_key_pending_invalidation' - ); - }); - - test('should log the proper error when savedObjectsClient create failed', async () => { - const logger = loggingSystemMock.create().get(); - const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); - await markApiKeyForInvalidation( - { apiKey: Buffer.from('123').toString('base64') }, - logger, - unsecuredSavedObjectsClient - ); - expect(logger.error).toHaveBeenCalledWith( - 'Failed to mark for API key [id="MTIz"] for invalidation: Fail' - ); - }); -}); diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.ts deleted file mode 100644 index 16bc2cf101102b..00000000000000 --- a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 } from '@kbn/core/server'; - -export const markApiKeyForInvalidation = async ( - { apiKey }: { apiKey: string | null }, - logger: Logger, - savedObjectsClient: SavedObjectsClientContract -): Promise => { - if (!apiKey) { - return; - } - try { - const apiKeyId = Buffer.from(apiKey, 'base64').toString().split(':')[0]; - await savedObjectsClient.create('api_key_pending_invalidation', { - apiKeyId, - createdAt: new Date().toISOString(), - }); - } catch (e) { - logger.error(`Failed to mark for API key [id="${apiKey}"] for invalidation: ${e.message}`); - } -}; diff --git a/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.test.ts b/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.test.ts new file mode 100644 index 00000000000000..4696fed3154cc9 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.test.ts @@ -0,0 +1,71 @@ +/* + * 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 { convertRuleIdsToKueryNode } from './convert_rule_ids_to_kuery_node'; + +describe('convertRuleIdsToKueryNode', () => { + test('should convert ids correctly', () => { + expect(convertRuleIdsToKueryNode(['1'])).toEqual({ + arguments: [ + { type: 'literal', value: 'alert.id' }, + { type: 'literal', value: 'alert:1' }, + { type: 'literal', value: false }, + ], + function: 'is', + type: 'function', + }); + }); + + test('should convert multiple ids correctly', () => { + expect(convertRuleIdsToKueryNode(['1', '22'])).toEqual({ + arguments: [ + { + arguments: [ + { + type: 'literal', + value: 'alert.id', + }, + { + type: 'literal', + value: 'alert:1', + }, + { + type: 'literal', + value: false, + }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { + type: 'literal', + value: 'alert.id', + }, + { + type: 'literal', + value: 'alert:22', + }, + { + type: 'literal', + value: false, + }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'or', + type: 'function', + }); + }); + + test('should convert empty ids array correctly', () => { + expect(convertRuleIdsToKueryNode([])).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.ts b/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.ts new file mode 100644 index 00000000000000..33f98b7b2ef528 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.ts @@ -0,0 +1,15 @@ +/* + * 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 { nodeBuilder } from '@kbn/es-query'; + +/** + * This utility converts array of rule ids into qNode filter + */ + +export const convertRuleIdsToKueryNode = (ids: string[]) => + nodeBuilder.or(ids.map((ruleId) => nodeBuilder.is('alert.id', `alert:${ruleId}`))); diff --git a/x-pack/plugins/alerting/server/lib/index.ts b/x-pack/plugins/alerting/server/lib/index.ts index 57c9a92a8d9158..31528c0d50683d 100644 --- a/x-pack/plugins/alerting/server/lib/index.ts +++ b/x-pack/plugins/alerting/server/lib/index.ts @@ -9,6 +9,7 @@ export { parseDuration, validateDurationSchema } from '../../common/parse_durati export type { ILicenseState } from './license_state'; export { LicenseState } from './license_state'; export { validateRuleTypeParams } from './validate_rule_type_params'; +export { validateMutatedRuleTypeParams } from './validate_mutated_rule_type_params'; export { getRuleNotifyWhenType } from './get_rule_notify_when_type'; export { verifyApiAccess } from './license_api_access'; export { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason'; @@ -26,3 +27,4 @@ export { } from './rule_execution_status'; export { getRecoveredAlerts } from './get_recovered_alerts'; export { createWrappedScopedClusterClientFactory } from './wrap_scoped_cluster_client'; +export { convertRuleIdsToKueryNode } from './convert_rule_ids_to_kuery_node'; diff --git a/x-pack/plugins/alerting/server/lib/validate_mutated_rule_type_params.ts b/x-pack/plugins/alerting/server/lib/validate_mutated_rule_type_params.ts new file mode 100644 index 00000000000000..52d7b768137b27 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/validate_mutated_rule_type_params.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { RuleTypeParams, RuleTypeParamsValidator } from '../types'; + +export function validateMutatedRuleTypeParams( + mutatedParams: Params, + origParams?: Params, + validator?: RuleTypeParamsValidator +): Params { + if (!validator) { + return mutatedParams; + } + + try { + if (validator.validateMutatedParams) { + return validator.validateMutatedParams(mutatedParams, origParams); + } + return mutatedParams; + } catch (err) { + throw Boom.badRequest(`Mutated params invalid: ${err.message}`); + } +} diff --git a/x-pack/plugins/alerting/server/routes/bulk_edit_rules.test.ts b/x-pack/plugins/alerting/server/routes/bulk_edit_rules.test.ts new file mode 100644 index 00000000000000..b70e4734ab4ffe --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/bulk_edit_rules.test.ts @@ -0,0 +1,188 @@ +/* + * 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 { bulkEditInternalRulesRoute } from './bulk_edit_rules'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { verifyApiAccess } from '../lib/license_api_access'; +import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { rulesClientMock } from '../rules_client.mock'; +import { SanitizedRule } from '../types'; + +const rulesClient = rulesClientMock.create(); +jest.mock('../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('bulkEditInternalRulesRoute', () => { + const mockedAlert: SanitizedRule<{}> = { + id: '1', + alertTypeId: '1', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + actions: [ + { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + consumer: 'bar', + name: 'abc', + tags: ['foo'], + enabled: true, + muteAll: false, + notifyWhen: 'onActionGroupChange', + createdBy: '', + updatedBy: '', + apiKeyOwner: '', + throttle: '30s', + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + }; + + const mockedAlerts: Array> = [mockedAlert]; + const bulkEditRequest = { + filter: '', + operations: [ + { + action: 'add', + field: 'tags', + value: ['alerting-1'], + }, + ], + }; + const bulkEditResult = { rules: mockedAlerts, errors: [], total: 1 }; + + it('bulk edits rules with tags action', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + bulkEditInternalRulesRoute(router, licenseState); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toBe('/internal/alerting/rules/_bulk_edit'); + + rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + body: bulkEditRequest, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toEqual({ + body: { + total: 1, + errors: [], + rules: [ + expect.objectContaining({ + id: '1', + name: 'abc', + tags: ['foo'], + actions: [ + { + group: 'default', + id: '2', + connector_type_id: 'test', + params: { + foo: true, + }, + }, + ], + }), + ], + }, + }); + + expect(rulesClient.bulkEdit).toHaveBeenCalledTimes(1); + expect(rulesClient.bulkEdit.mock.calls[0]).toEqual([bulkEditRequest]); + + expect(res.ok).toHaveBeenCalled(); + }); + + it('ensures the license allows bulk editing rules', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditResult); + + bulkEditInternalRulesRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + body: bulkEditRequest, + } + ); + + await handler(context, req, res); + + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + it('ensures the license check prevents bulk editing rules', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('Failure'); + }); + + bulkEditInternalRulesRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + body: bulkEditRequest, + } + ); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`); + }); + + it('ensures the rule type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + bulkEditInternalRulesRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + rulesClient.bulkEdit.mockRejectedValue(new RuleTypeDisabledError('Fail', 'license_invalid')); + + const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/bulk_edit_rules.ts b/x-pack/plugins/alerting/server/routes/bulk_edit_rules.ts new file mode 100644 index 00000000000000..6588a46e1d9141 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/bulk_edit_rules.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '@kbn/core/server'; + +import { ILicenseState, RuleTypeDisabledError } from '../lib'; +import { verifyAccessAndContext, rewriteRule, handleDisabledApiKeysError } from './lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types'; + +const ruleActionSchema = schema.object({ + group: schema.string(), + id: schema.string(), + params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), +}); + +const operationsSchema = schema.arrayOf( + schema.oneOf([ + schema.object({ + operation: schema.oneOf([ + schema.literal('add'), + schema.literal('delete'), + schema.literal('set'), + ]), + field: schema.literal('tags'), + value: schema.arrayOf(schema.string()), + }), + schema.object({ + operation: schema.oneOf([schema.literal('add'), schema.literal('set')]), + field: schema.literal('actions'), + value: schema.arrayOf(ruleActionSchema), + }), + ]), + { minSize: 1 } +); + +const bodySchema = schema.object({ + filter: schema.maybe(schema.string()), + ids: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + operations: operationsSchema, +}); + +interface BuildBulkEditRulesRouteParams { + licenseState: ILicenseState; + path: string; + router: IRouter; +} + +const buildBulkEditRulesRoute = ({ licenseState, path, router }: BuildBulkEditRulesRouteParams) => { + router.post( + { + path, + validate: { + body: bodySchema, + }, + }, + handleDisabledApiKeysError( + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = (await context.alerting).getRulesClient(); + const { filter, operations, ids } = req.body; + + try { + const bulkEditResults = await rulesClient.bulkEdit({ + filter, + ids: ids as string[], + operations, + }); + return res.ok({ + body: { ...bulkEditResults, rules: bulkEditResults.rules.map(rewriteRule) }, + }); + } catch (e) { + if (e instanceof RuleTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } + }) + ) + ) + ); +}; + +export const bulkEditInternalRulesRoute = ( + router: IRouter, + licenseState: ILicenseState +) => + buildBulkEditRulesRoute({ + licenseState, + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_bulk_edit`, + router, + }); diff --git a/x-pack/plugins/alerting/server/routes/find_rules.ts b/x-pack/plugins/alerting/server/routes/find_rules.ts index ef8b8b29057c0f..725c7fc2a69dd8 100644 --- a/x-pack/plugins/alerting/server/routes/find_rules.ts +++ b/x-pack/plugins/alerting/server/routes/find_rules.ts @@ -5,13 +5,17 @@ * 2.0. */ -import { omit } from 'lodash'; import { IRouter } from '@kbn/core/server'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import { schema } from '@kbn/config-schema'; import { ILicenseState } from '../lib'; import { FindOptions, FindResult } from '../rules_client'; -import { RewriteRequestCase, RewriteResponseCase, verifyAccessAndContext } from './lib'; +import { + RewriteRequestCase, + RewriteResponseCase, + verifyAccessAndContext, + rewriteRule, +} from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, @@ -70,49 +74,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ return { ...restOfResult, per_page: perPage, - data: data.map( - ({ - alertTypeId, - createdBy, - updatedBy, - createdAt, - updatedAt, - apiKeyOwner, - notifyWhen, - muteAll, - mutedInstanceIds, - executionStatus, - actions, - scheduledTaskId, - snoozeEndTime, - ...rest - }) => ({ - ...rest, - rule_type_id: alertTypeId, - created_by: createdBy, - updated_by: updatedBy, - created_at: createdAt, - updated_at: updatedAt, - api_key_owner: apiKeyOwner, - notify_when: notifyWhen, - mute_all: muteAll, - muted_alert_ids: mutedInstanceIds, - scheduled_task_id: scheduledTaskId, - // Remove this object spread boolean check after snoozeEndTime is added to the public API - ...(snoozeEndTime !== undefined ? { snooze_end_time: snoozeEndTime } : {}), - execution_status: executionStatus && { - ...omit(executionStatus, 'lastExecutionDate', 'lastDuration'), - last_execution_date: executionStatus.lastExecutionDate, - last_duration: executionStatus.lastDuration, - }, - actions: actions.map(({ group, id, actionTypeId, params }) => ({ - group, - id, - params, - connector_type_id: actionTypeId, - })), - }) - ), + data: data.map(rewriteRule), }; }; diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index 392ec591d96087..2ef75ce269220d 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -30,6 +30,7 @@ import { muteAlertRoute } from './mute_alert'; import { unmuteAllRuleRoute } from './unmute_all_rule'; import { unmuteAlertRoute } from './unmute_alert'; import { updateRuleApiKeyRoute } from './update_rule_api_key'; +import { bulkEditInternalRulesRoute } from './bulk_edit_rules'; import { snoozeRuleRoute } from './snooze_rule'; import { unsnoozeRuleRoute } from './unsnooze_rule'; @@ -65,6 +66,7 @@ export function defineRoutes(opts: RouteOptions) { unmuteAllRuleRoute(router, licenseState); unmuteAlertRoute(router, licenseState); updateRuleApiKeyRoute(router, licenseState); + bulkEditInternalRulesRoute(router, licenseState); snoozeRuleRoute(router, licenseState); unsnoozeRuleRoute(router, licenseState); } diff --git a/x-pack/plugins/alerting/server/routes/lib/index.ts b/x-pack/plugins/alerting/server/routes/lib/index.ts index 2c14660ae47de0..e772f091bb0591 100644 --- a/x-pack/plugins/alerting/server/routes/lib/index.ts +++ b/x-pack/plugins/alerting/server/routes/lib/index.ts @@ -18,3 +18,4 @@ export type { } from './rewrite_request_case'; export { verifyAccessAndContext } from './verify_access_and_context'; export { countUsageOfPredefinedIds } from './count_usage_of_predefined_ids'; +export { rewriteRule } from './rewrite_rule'; diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts new file mode 100644 index 00000000000000..537d42bbc4f470 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.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. + */ +import { omit } from 'lodash'; + +import { RuleTypeParams, SanitizedRule } from '../../types'; + +export const rewriteRule = ({ + alertTypeId, + createdBy, + updatedBy, + createdAt, + updatedAt, + apiKeyOwner, + notifyWhen, + muteAll, + mutedInstanceIds, + executionStatus, + actions, + scheduledTaskId, + snoozeEndTime, + ...rest +}: SanitizedRule) => ({ + ...rest, + rule_type_id: alertTypeId, + created_by: createdBy, + updated_by: updatedBy, + created_at: createdAt, + updated_at: updatedAt, + api_key_owner: apiKeyOwner, + notify_when: notifyWhen, + mute_all: muteAll, + muted_alert_ids: mutedInstanceIds, + scheduled_task_id: scheduledTaskId, + // Remove this object spread boolean check after snoozeEndTime is added to the public API + ...(snoozeEndTime !== undefined ? { snooze_end_time: snoozeEndTime } : {}), + execution_status: executionStatus && { + ...omit(executionStatus, 'lastExecutionDate', 'lastDuration'), + last_execution_date: executionStatus.lastExecutionDate, + last_duration: executionStatus.lastDuration, + }, + actions: actions.map(({ group, id, actionTypeId, params }) => ({ + group, + id, + params, + connector_type_id: actionTypeId, + })), +}); diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts index bc5c9c0a5e0cd0..302824221ded83 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -32,6 +32,7 @@ const createRulesClientMock = () => { getAlertSummary: jest.fn(), getExecutionLogForRule: jest.fn(), getSpaceId: jest.fn(), + bulkEdit: jest.fn(), snooze: jest.fn(), unsnooze: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/audit_events.ts index a60cc503d3c8e1..0d722498f50aa1 100644 --- a/x-pack/plugins/alerting/server/rules_client/audit_events.ts +++ b/x-pack/plugins/alerting/server/rules_client/audit_events.ts @@ -23,6 +23,7 @@ export enum RuleAuditAction { MUTE_ALERT = 'rule_alert_mute', UNMUTE_ALERT = 'rule_alert_unmute', AGGREGATE = 'rule_aggregate', + BULK_EDIT = 'rule_bulk_edit', GET_EXECUTION_LOG = 'rule_get_execution_log', SNOOZE = 'rule_snooze', UNSNOOZE = 'rule_unsnooze', @@ -35,6 +36,7 @@ const eventVerbs: Record = { rule_get: ['access', 'accessing', 'accessed'], rule_resolve: ['access', 'accessing', 'accessed'], rule_update: ['update', 'updating', 'updated'], + rule_bulk_edit: ['update', 'updating', 'updated'], rule_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'], rule_enable: ['enable', 'enabling', 'enabled'], rule_disable: ['disable', 'disabling', 'disabled'], @@ -59,6 +61,7 @@ const eventTypes: Record = { rule_get: 'access', rule_resolve: 'access', rule_update: 'change', + rule_bulk_edit: 'change', rule_update_api_key: 'change', rule_enable: 'change', rule_disable: 'change', diff --git a/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.test.ts new file mode 100644 index 00000000000000..8ba3b551073cfc --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.test.ts @@ -0,0 +1,171 @@ +/* + * 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 { applyBulkEditOperation } from './apply_bulk_edit_operation'; +import type { Rule } from '../../types'; + +describe('applyBulkEditOperation', () => { + describe('tags operations', () => { + test('should add tag', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['add-tag'], + operation: 'add', + }, + ruleMock + ) + ).toHaveProperty('tags', ['tag-1', 'tag-2', 'add-tag']); + }); + + test('should add multiple tags', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['add-tag-1', 'add-tag-2'], + operation: 'add', + }, + ruleMock + ) + ).toHaveProperty('tags', ['tag-1', 'tag-2', 'add-tag-1', 'add-tag-2']); + }); + + test('should not have duplicated tags when added existed ones', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['tag-1', 'tag-3'], + operation: 'add', + }, + ruleMock + ) + ).toHaveProperty('tags', ['tag-1', 'tag-2', 'tag-3']); + }); + + test('should delete tag', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['tag-1'], + operation: 'delete', + }, + ruleMock + ) + ).toHaveProperty('tags', ['tag-2']); + }); + + test('should delete multiple tags', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['tag-1', 'tag-2'], + operation: 'delete', + }, + ruleMock + ) + ).toHaveProperty('tags', []); + }); + + test('should rewrite tags', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['rewrite-tag'], + operation: 'set', + }, + ruleMock + ) + ).toHaveProperty('tags', ['rewrite-tag']); + }); + }); + + describe('actions operations', () => { + test('should add actions', () => { + const ruleMock = { + actions: [{ id: 'mock-action-id', group: 'default', params: {} }], + }; + expect( + applyBulkEditOperation( + { + field: 'actions', + value: [ + { id: 'mock-add-action-id-1', group: 'default', params: {} }, + { id: 'mock-add-action-id-2', group: 'default', params: {} }, + ], + operation: 'add', + }, + ruleMock + ) + ).toHaveProperty('actions', [ + { id: 'mock-action-id', group: 'default', params: {} }, + { id: 'mock-add-action-id-1', group: 'default', params: {} }, + { id: 'mock-add-action-id-2', group: 'default', params: {} }, + ]); + }); + + test('should add action with different params and same id', () => { + const ruleMock = { + actions: [{ id: 'mock-action-id', group: 'default', params: { test: 1 } }], + }; + expect( + applyBulkEditOperation( + { + field: 'actions', + value: [{ id: 'mock-action-id', group: 'default', params: { test: 2 } }], + operation: 'add', + }, + ruleMock + ) + ).toHaveProperty('actions', [ + { id: 'mock-action-id', group: 'default', params: { test: 1 } }, + { id: 'mock-action-id', group: 'default', params: { test: 2 } }, + ]); + }); + + test('should rewrite actions', () => { + const ruleMock = { + actions: [{ id: 'mock-action-id', group: 'default', params: {} }], + }; + expect( + applyBulkEditOperation( + { + field: 'actions', + value: [{ id: 'mock-rewrite-action-id-1', group: 'default', params: {} }], + operation: 'set', + }, + ruleMock + ) + ).toHaveProperty('actions', [ + { id: 'mock-rewrite-action-id-1', group: 'default', params: {} }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.ts b/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.ts new file mode 100644 index 00000000000000..a41e3f0dfa7f88 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.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 { set, get } from 'lodash'; +import type { BulkEditOperation, BulkEditFields } from '../rules_client'; + +// defining an union type that will passed directly to generic function as a workaround for the issue similar to +// https://github.com/microsoft/TypeScript/issues/29479 +type AddItemToArray = + | Extract }>['value'][number] + | Extract }>['value'][number]; + +/** + * this method takes BulkEdit operation and applies it to rule, by mutating it + * @param operation BulkEditOperation + * @param rule object rule to update + * @returns modified rule + */ +export const applyBulkEditOperation = (operation: BulkEditOperation, rule: R) => { + const addItemsToArray = (arr: T[], items: T[]): T[] => Array.from(new Set([...arr, ...items])); + + const deleteItemsFromArray = (arr: T[], items: T[]): T[] => { + const itemsSet = new Set(items); + return arr.filter((item) => !itemsSet.has(item)); + }; + + switch (operation.operation) { + case 'set': + set(rule, operation.field, operation.value); + break; + + case 'add': + set( + rule, + operation.field, + addItemsToArray(get(rule, operation.field) ?? [], operation.value) + ); + break; + + case 'delete': + set( + rule, + operation.field, + deleteItemsFromArray(get(rule, operation.field) ?? [], operation.value) + ); + break; + } + + return rule; +}; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/index.ts b/x-pack/plugins/alerting/server/rules_client/lib/index.ts index 3ad3e118770647..fc31cd6a98d555 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/index.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/index.ts @@ -7,3 +7,5 @@ export { mapSortField } from './map_sort_field'; export { validateOperationOnAttributes } from './validate_attributes'; +export { retryIfBulkEditConflicts } from './retry_if_bulk_edit_conflicts'; +export { applyBulkEditOperation } from './apply_bulk_edit_operation'; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.test.ts new file mode 100644 index 00000000000000..ae2a83614ac20c --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { KueryNode } from '@kbn/es-query'; + +import { + retryIfBulkEditConflicts, + RetryForConflictsAttempts, +} from './retry_if_bulk_edit_conflicts'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; + +const mockFilter: KueryNode = { + type: 'function', + value: 'mock', +}; + +const mockOperationName = 'conflict-retryable-operation'; +const mockLogger = loggingSystemMock.create().get(); +const mockSuccessfulResult = { + apiKeysToInvalidate: [], + rules: [ + { id: '1', type: 'alert', attributes: {} }, + { id: '2', type: 'alert', attributes: { name: 'Test rule 2' } }, + ], + resultSavedObjects: [ + { id: '1', type: 'alert', attributes: {}, references: [] }, + { id: '2', type: 'alert', attributes: { name: 'Test rule 2' }, references: [] }, + ], + errors: [], +}; + +async function OperationSuccessful() { + return mockSuccessfulResult; +} + +const conflictOperationMock = jest.fn(); + +function getOperationConflictsTimes(times: number) { + return async function OperationConflictsTimes() { + conflictOperationMock(); + times--; + if (times >= 0) { + return { + ...mockSuccessfulResult, + resultSavedObjects: [ + { id: '1', type: 'alert', attributes: {}, references: [] }, + { + id: '2', + type: 'alert', + attributes: {}, + references: [], + error: { + statusCode: 409, + error: 'Conflict', + message: 'Saved object [alert/2] conflict', + }, + }, + ], + }; + } + return mockSuccessfulResult; + }; +} + +describe('retryIfBulkEditConflicts', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should work when operation is a success', async () => { + const result = await retryIfBulkEditConflicts( + mockLogger, + mockOperationName, + OperationSuccessful, + mockFilter + ); + expect(result).toEqual({ + apiKeysToInvalidate: [], + errors: [], + results: [ + { + attributes: {}, + id: '1', + references: [], + type: 'alert', + }, + { + attributes: { + name: 'Test rule 2', + }, + id: '2', + references: [], + type: 'alert', + }, + ], + }); + }); + + test(`should throw error when operation fails`, async () => { + await expect( + retryIfBulkEditConflicts( + mockLogger, + mockOperationName, + async () => { + throw Error('Test failure'); + }, + mockFilter + ) + ).rejects.toThrowError('Test failure'); + }); + + test(`should return conflict errors when number of retries exceeds ${RetryForConflictsAttempts}`, async () => { + const result = await retryIfBulkEditConflicts( + mockLogger, + mockOperationName, + getOperationConflictsTimes(RetryForConflictsAttempts + 1), + mockFilter + ); + + expect(result.errors).toEqual([ + { + message: 'Saved object [alert/2] conflict', + rule: { + id: '2', + name: 'Test rule 2', + }, + }, + ]); + expect(mockLogger.warn).toBeCalledWith(`${mockOperationName} conflicts, exceeded retries`); + }); + + for (let i = 1; i <= RetryForConflictsAttempts; i++) { + test(`should work when operation conflicts ${i} times`, async () => { + const result = await retryIfBulkEditConflicts( + mockLogger, + mockOperationName, + getOperationConflictsTimes(i), + mockFilter + ); + expect(result).toBe(result); + }); + } +}); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.ts b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.ts new file mode 100644 index 00000000000000..9e1e60acb768fb --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.ts @@ -0,0 +1,166 @@ +/* + * 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 pMap from 'p-map'; +import { chunk } from 'lodash'; +import { KueryNode } from '@kbn/es-query'; +import { Logger, SavedObjectsBulkUpdateObject, SavedObjectsUpdateResponse } from '@kbn/core/server'; +import { convertRuleIdsToKueryNode } from '../../lib'; +import { BulkEditError } from '../rules_client'; +import { RawRule } from '../../types'; + +// number of times to retry when conflicts occur +export const RetryForConflictsAttempts = 2; + +// milliseconds to wait before retrying when conflicts occur +// note: we considered making this random, to help avoid a stampede, but +// with 1 retry it probably doesn't matter, and adding randomness could +// make it harder to diagnose issues +const RetryForConflictsDelay = 250; + +// max number of failed SO ids in one retry filter +const MaxIdsNumberInRetryFilter = 1000; + +type BulkEditOperation = (filter: KueryNode | null) => Promise<{ + apiKeysToInvalidate: string[]; + rules: Array>; + resultSavedObjects: Array>; + errors: BulkEditError[]; +}>; + +interface ReturnRetry { + apiKeysToInvalidate: string[]; + results: Array>; + errors: BulkEditError[]; +} + +/** + * Retries BulkEdit requests + * If in response are presents conflicted savedObjects(409 statusCode), this util constructs filter with failed SO ids and retries bulkEdit operation until + * all SO updated or number of retries exceeded + * @param logger + * @param name + * @param bulkEditOperation + * @param filter - KueryNode filter + * @param retries - number of retries left + * @param accApiKeysToInvalidate - accumulated apiKeys that need to be invalidated + * @param accResults - accumulated updated savedObjects + * @param accErrors - accumulated conflict errors + * @returns Promise + */ +export const retryIfBulkEditConflicts = async ( + logger: Logger, + name: string, + bulkEditOperation: BulkEditOperation, + filter: KueryNode | null, + retries: number = RetryForConflictsAttempts, + accApiKeysToInvalidate: string[] = [], + accResults: Array> = [], + accErrors: BulkEditError[] = [] +): Promise => { + // run the operation, return if no errors or throw if not a conflict error + try { + const { + apiKeysToInvalidate: localApiKeysToInvalidate, + resultSavedObjects, + errors: localErrors, + rules: localRules, + } = await bulkEditOperation(filter); + + const conflictErrorMap = resultSavedObjects.reduce>( + (acc, item) => { + if (item.type === 'alert' && item?.error?.statusCode === 409) { + return acc.set(item.id, { message: item.error.message }); + } + return acc; + }, + new Map() + ); + + const results = [...accResults, ...resultSavedObjects.filter((res) => res.error === undefined)]; + const apiKeysToInvalidate = [...accApiKeysToInvalidate, ...localApiKeysToInvalidate]; + const errors = [...accErrors, ...localErrors]; + + if (conflictErrorMap.size === 0) { + return { + apiKeysToInvalidate, + results, + errors, + }; + } + + if (retries <= 0) { + logger.warn(`${name} conflicts, exceeded retries`); + + const conflictErrors = localRules + .filter((obj) => conflictErrorMap.has(obj.id)) + .map((obj) => ({ + message: conflictErrorMap.get(obj.id)?.message ?? 'n/a', + rule: { + id: obj.id, + name: obj.attributes?.name ?? 'n/a', + }, + })); + + return { + apiKeysToInvalidate, + results, + errors: [...errors, ...conflictErrors], + }; + } + + const ids = Array.from(conflictErrorMap.keys()); + logger.debug(`${name} conflicts, retrying ..., ${ids.length} saved objects conflicted`); + + // delay before retry + await waitBeforeNextRetry(retries); + + // here, we construct filter query with ids. But, due to a fact that number of conflicted saved objects can exceed few thousands we can encounter following error: + // "all shards failed: search_phase_execution_exception: [query_shard_exception] Reason: failed to create query: maxClauseCount is set to 2621" + // That's why we chunk processing ids into pieces by size equals to MaxIdsNumberInRetryFilter + return ( + await pMap( + chunk(ids, MaxIdsNumberInRetryFilter), + async (queryIds) => + retryIfBulkEditConflicts( + logger, + name, + bulkEditOperation, + convertRuleIdsToKueryNode(queryIds), + retries - 1, + apiKeysToInvalidate, + results, + errors + ), + { + concurrency: 1, + } + ) + ).reduce( + (acc, item) => { + return { + results: [...acc.results, ...item.results], + apiKeysToInvalidate: [...acc.apiKeysToInvalidate, ...item.apiKeysToInvalidate], + errors: [...acc.errors, ...item.errors], + }; + }, + { results: [], apiKeysToInvalidate: [], errors: [] } + ); + } catch (err) { + throw err; + } +}; + +// exponential delay before retry with adding random delay +async function waitBeforeNextRetry(retries: number): Promise { + const exponentialDelayMultiplier = 1 + (RetryForConflictsAttempts - retries) ** 2; + const randomDelayMs = Math.floor(Math.random() * 100); + + await new Promise((resolve) => + setTimeout(resolve, RetryForConflictsDelay * exponentialDelayMultiplier + randomDelayMs) + ); +} diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index e229b15fcd1cdc..75398a66687552 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -6,8 +6,9 @@ */ import Semver from 'semver'; +import pMap from 'p-map'; import Boom from '@hapi/boom'; -import { omit, isEqual, map, uniq, pick, truncate, trim, mapValues } from 'lodash'; +import { omit, isEqual, map, uniq, pick, truncate, trim, mapValues, cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { fromKueryExpression, KueryNode, nodeBuilder } from '@kbn/es-query'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; @@ -19,6 +20,8 @@ import { PluginInitializerContext, SavedObjectsUtils, SavedObjectAttributes, + SavedObjectsBulkUpdateObject, + SavedObjectsUpdateResponse, } from '@kbn/core/server'; import { ActionsClient, ActionsAuthorization } from '@kbn/actions-plugin/server'; import { @@ -53,7 +56,13 @@ import { PartialRuleWithLegacyId, RawAlertInstance as RawAlert, } from '../types'; -import { validateRuleTypeParams, ruleExecutionStatusFromRaw, getRuleNotifyWhenType } from '../lib'; +import { + validateRuleTypeParams, + ruleExecutionStatusFromRaw, + getRuleNotifyWhenType, + validateMutatedRuleTypeParams, + convertRuleIdsToKueryNode, +} from '../lib'; import { taskInstanceToAlertTaskInstance } from '../task_runner/alert_task_instance'; import { RegistryRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { @@ -69,9 +78,14 @@ import { alertSummaryFromEventLog } from '../lib/alert_summary_from_event_log'; import { parseDuration } from '../../common/parse_duration'; import { retryIfConflicts } from '../lib/retry_if_conflicts'; import { partiallyUpdateAlert } from '../saved_objects'; -import { markApiKeyForInvalidation } from '../invalidate_pending_api_keys/mark_api_key_for_invalidation'; +import { bulkMarkApiKeysForInvalidation } from '../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { ruleAuditEvent, RuleAuditAction } from './audit_events'; -import { mapSortField, validateOperationOnAttributes } from './lib'; +import { + mapSortField, + validateOperationOnAttributes, + retryIfBulkEditConflicts, + applyBulkEditOperation, +} from './lib'; import { getRuleExecutionStatusPending } from '../lib/rule_execution_status'; import { Alert } from '../alert'; import { EVENT_LOG_ACTIONS } from '../plugin'; @@ -141,6 +155,15 @@ export interface RuleAggregation { }; } +export interface RuleBulkEditAggregation { + alertTypeId: { + buckets: Array<{ + key: string[]; + doc_count: number; + }>; + }; +} + export interface ConstructorOptions { logger: Logger; taskManager: TaskManagerStartContract; @@ -186,6 +209,63 @@ export interface FindOptions extends IndexType { filter?: string; } +export type BulkEditFields = keyof Pick; + +export type BulkEditOperation = + | { + operation: 'add' | 'delete' | 'set'; + field: Extract; + value: string[]; + } + | { + operation: 'add' | 'set'; + field: Extract; + value: NormalizedAlertAction[]; + }; + +// schedule, throttle, notifyWhen is commented out before https://github.com/elastic/kibana/issues/124850 will be implemented +// | { +// operation: 'set'; +// field: Extract; +// value: Rule['schedule']; +// } +// | { +// operation: 'set'; +// field: Extract; +// value: Rule['throttle']; +// } +// | { +// operation: 'set'; +// field: Extract; +// value: Rule['notifyWhen']; +// }; + +type RuleParamsModifier = (params: Params) => Promise; + +export interface BulkEditOptionsFilter { + filter?: string | KueryNode; + operations: BulkEditOperation[]; + paramsModifier?: RuleParamsModifier; +} + +export interface BulkEditOptionsIds { + ids: string[]; + operations: BulkEditOperation[]; + paramsModifier?: RuleParamsModifier; +} + +export type BulkEditOptions = + | BulkEditOptionsFilter + | BulkEditOptionsIds; + +export interface BulkEditError { + message: string; + rule: { + id: string; + name: string; + }; +} + export interface AggregateOptions extends IndexType { search?: string; defaultSearchOperator?: 'AND' | 'OR'; @@ -281,6 +361,10 @@ const extractedSavedObjectParamReferenceNamePrefix = 'param:'; // NOTE: Changing this prefix will require a migration to update the prefix in all existing `rule` saved objects const preconfiguredConnectorActionRefPrefix = 'preconfigured:'; +const MAX_RULES_NUMBER_FOR_BULK_EDIT = 10000; +const API_KEY_GENERATE_CONCURRENCY = 50; +const RULE_TYPE_CHECKS_CONCURRENCY = 50; + const alertingAuthorizationFilterOpts: AlertingAuthorizationFilterOpts = { type: AlertingAuthorizationFilterType.KQL, fieldNames: { ruleTypeId: 'alert.attributes.alertTypeId', consumer: 'alert.attributes.consumer' }, @@ -456,11 +540,12 @@ export class RulesClient { ); } catch (e) { // Avoid unused API key - markApiKeyForInvalidation( - { apiKey: rawRule.apiKey }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: rawRule.apiKey ? [rawRule.apiKey] : [] }, this.logger, this.unsecuredSavedObjectsClient ); + throw e; } if (data.enabled) { @@ -1069,8 +1154,8 @@ export class RulesClient { await Promise.all([ taskIdToRemove ? this.taskManager.removeIfExists(taskIdToRemove) : null, apiKeyToInvalidate - ? markApiKeyForInvalidation( - { apiKey: apiKeyToInvalidate }, + ? bulkMarkApiKeysForInvalidation( + { apiKeys: [apiKeyToInvalidate] }, this.logger, this.unsecuredSavedObjectsClient ) @@ -1146,8 +1231,8 @@ export class RulesClient { await Promise.all([ alertSavedObject.attributes.apiKey - ? markApiKeyForInvalidation( - { apiKey: alertSavedObject.attributes.apiKey }, + ? bulkMarkApiKeysForInvalidation( + { apiKeys: [alertSavedObject.attributes.apiKey] }, this.logger, this.unsecuredSavedObjectsClient ) @@ -1246,11 +1331,12 @@ export class RulesClient { ); } catch (e) { // Avoid unused API key - markApiKeyForInvalidation( - { apiKey: createAttributes.apiKey }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: createAttributes.apiKey ? [createAttributes.apiKey] : [] }, this.logger, this.unsecuredSavedObjectsClient ); + throw e; } @@ -1271,6 +1357,349 @@ export class RulesClient { ); } + public async bulkEdit( + options: BulkEditOptions + ): Promise<{ + rules: Array>; + errors: BulkEditError[]; + total: number; + }> { + const queryFilter = (options as BulkEditOptionsFilter).filter; + const ids = (options as BulkEditOptionsIds).ids; + + if (ids && queryFilter) { + throw Boom.badRequest( + "Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method arguments" + ); + } + + let qNodeQueryFilter: null | KueryNode; + if (!queryFilter) { + qNodeQueryFilter = null; + } else if (typeof queryFilter === 'string') { + qNodeQueryFilter = fromKueryExpression(queryFilter); + } else { + qNodeQueryFilter = queryFilter; + } + + const qNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : qNodeQueryFilter; + let authorizationTuple; + try { + authorizationTuple = await this.authorization.getFindAuthorizationFilter( + AlertingAuthorizationEntity.Rule, + alertingAuthorizationFilterOpts + ); + } catch (error) { + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.BULK_EDIT, + error, + }) + ); + throw error; + } + const { filter: authorizationFilter } = authorizationTuple; + const qNodeFilterWithAuth = + authorizationFilter && qNodeFilter + ? nodeBuilder.and([qNodeFilter, authorizationFilter as KueryNode]) + : qNodeFilter; + + const { aggregations, total } = await this.unsecuredSavedObjectsClient.find< + RawRule, + RuleBulkEditAggregation + >({ + filter: qNodeFilterWithAuth, + page: 1, + perPage: 0, + type: 'alert', + aggs: { + alertTypeId: { + multi_terms: { + terms: [ + { field: 'alert.attributes.alertTypeId' }, + { field: 'alert.attributes.consumer' }, + ], + }, + }, + }, + }); + + if (total > MAX_RULES_NUMBER_FOR_BULK_EDIT) { + throw Boom.badRequest( + `More than ${MAX_RULES_NUMBER_FOR_BULK_EDIT} rules matched for bulk edit` + ); + } + const buckets = aggregations?.alertTypeId.buckets; + + if (buckets === undefined) { + throw Error('No rules found for bulk edit'); + } + + await pMap( + buckets, + async ({ key: [ruleType, consumer] }) => { + this.ruleTypeRegistry.ensureRuleTypeEnabled(ruleType); + + try { + await this.authorization.ensureAuthorized({ + ruleTypeId: ruleType, + consumer, + operation: WriteOperations.BulkEdit, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.BULK_EDIT, + error, + }) + ); + throw error; + } + }, + { concurrency: RULE_TYPE_CHECKS_CONCURRENCY } + ); + + const { apiKeysToInvalidate, results, errors } = await retryIfBulkEditConflicts( + this.logger, + `rulesClient.update('operations=${JSON.stringify(options.operations)}, paramsModifier=${ + options.paramsModifier ? '[Function]' : undefined + }')`, + (filterKueryNode: KueryNode | null) => + this.bulkEditOcc({ + filter: filterKueryNode, + operations: options.operations, + paramsModifier: options.paramsModifier, + }), + qNodeFilterWithAuth + ); + + await bulkMarkApiKeysForInvalidation( + { apiKeys: apiKeysToInvalidate }, + this.logger, + this.unsecuredSavedObjectsClient + ); + + const updatedRules = results.map(({ id, attributes, references }) => { + return this.getAlertFromRaw( + id, + attributes.alertTypeId as string, + attributes as RawRule, + references, + false + ); + }); + + return { rules: updatedRules, errors, total }; + } + + private async bulkEditOcc({ + filter, + operations, + paramsModifier, + }: { + filter: KueryNode | null; + operations: BulkEditOptions['operations']; + paramsModifier: BulkEditOptions['paramsModifier']; + }): Promise<{ + apiKeysToInvalidate: string[]; + rules: Array>; + resultSavedObjects: Array>; + errors: BulkEditError[]; + }> { + const rulesFinder = + await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser( + { + filter, + type: 'alert', + perPage: 100, + ...(this.namespace ? { namespaces: [this.namespace] } : undefined), + } + ); + + const rules: Array> = []; + const errors: BulkEditError[] = []; + const apiKeysToInvalidate: string[] = []; + const apiKeysMap = new Map(); + const username = await this.getUserName(); + + for await (const response of rulesFinder.find()) { + await pMap( + response.saved_objects, + async (rule) => { + try { + if (rule.attributes.apiKey) { + apiKeysMap.set(rule.id, { oldApiKey: rule.attributes.apiKey }); + } + + const ruleType = this.ruleTypeRegistry.get(rule.attributes.alertTypeId); + + let attributes = cloneDeep(rule.attributes); + let ruleActions = { + actions: this.injectReferencesIntoActions( + rule.id, + rule.attributes.actions, + rule.references || [] + ), + }; + for (const operation of operations) { + switch (operation.field) { + case 'actions': + await this.validateActions(ruleType, operation.value); + ruleActions = applyBulkEditOperation(operation, ruleActions); + break; + default: + attributes = applyBulkEditOperation(operation, attributes); + } + } + + // validate schedule interval + if (attributes.schedule.interval) { + const isIntervalInvalid = + parseDuration(attributes.schedule.interval as string) < + this.minimumScheduleIntervalInMs; + if (isIntervalInvalid && this.minimumScheduleInterval.enforce) { + throw Error( + `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); + } else if (isIntervalInvalid && !this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule schedule interval (${attributes.schedule.interval}) for "${ruleType.id}" rule type with ID "${attributes.id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + ); + } + } + + const ruleParams = paramsModifier + ? await paramsModifier(attributes.params as Params) + : attributes.params; + + // validate rule params + const validatedAlertTypeParams = validateRuleTypeParams( + ruleParams, + ruleType.validate?.params + ); + const validatedMutatedAlertTypeParams = validateMutatedRuleTypeParams( + validatedAlertTypeParams, + rule.attributes.params, + ruleType.validate?.params + ); + + const { + actions: rawAlertActions, + references, + params: updatedParams, + } = await this.extractReferences( + ruleType, + ruleActions.actions, + validatedMutatedAlertTypeParams + ); + + // create API key + let createdAPIKey = null; + try { + createdAPIKey = attributes.enabled + ? await this.createAPIKey(this.generateAPIKeyName(ruleType.id, attributes.name)) + : null; + } catch (error) { + throw Error(`Error updating rule: could not create API key - ${error.message}`); + } + + const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); + + // collect generated API keys + if (apiKeyAttributes.apiKey) { + apiKeysMap.set(rule.id, { + ...apiKeysMap.get(rule.id), + newApiKey: apiKeyAttributes.apiKey, + }); + } + + // get notifyWhen + const notifyWhen = getRuleNotifyWhenType( + attributes.notifyWhen, + attributes.throttle ?? null + ); + + const updatedAttributes = this.updateMeta({ + ...attributes, + ...apiKeyAttributes, + params: updatedParams as RawRule['params'], + actions: rawAlertActions, + notifyWhen, + updatedBy: username, + updatedAt: new Date().toISOString(), + }); + + // add mapped_params + const mappedParams = getMappedParams(updatedParams); + + if (Object.keys(mappedParams).length) { + updatedAttributes.mapped_params = mappedParams; + } + + rules.push({ + ...rule, + references, + attributes: updatedAttributes, + }); + } catch (error) { + errors.push({ + message: error.message, + rule: { + id: rule.id, + name: rule.attributes?.name, + }, + }); + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.BULK_EDIT, + error, + }) + ); + } + }, + { concurrency: API_KEY_GENERATE_CONCURRENCY } + ); + } + + let result; + try { + result = await this.unsecuredSavedObjectsClient.bulkUpdate(rules); + } catch (e) { + // avoid unused newly generated API keys + if (apiKeysMap.size > 0) { + await bulkMarkApiKeysForInvalidation( + { + apiKeys: Array.from(apiKeysMap.values()).reduce((acc, value) => { + if (value.newApiKey) { + acc.push(value.newApiKey); + } + return acc; + }, []), + }, + this.logger, + this.unsecuredSavedObjectsClient + ); + } + throw e; + } + + result.saved_objects.map(({ id, error }) => { + const oldApiKey = apiKeysMap.get(id)?.oldApiKey; + const newApiKey = apiKeysMap.get(id)?.newApiKey; + + // if SO wasn't saved and has new API key it will be invalidated + if (error && newApiKey) { + apiKeysToInvalidate.push(newApiKey); + // if SO saved and has old Api Key it will be invalidate + } else if (!error && oldApiKey) { + apiKeysToInvalidate.push(oldApiKey); + } + }); + + return { apiKeysToInvalidate, resultSavedObjects: result.saved_objects, errors, rules }; + } + private apiKeyAsAlertAttributes( apiKey: CreateAPIKeyResult | null, username: string | null @@ -1373,8 +1802,8 @@ export class RulesClient { await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version }); } catch (e) { // Avoid unused API key - markApiKeyForInvalidation( - { apiKey: updateAttributes.apiKey }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: updateAttributes.apiKey ? [updateAttributes.apiKey] : [] }, this.logger, this.unsecuredSavedObjectsClient ); @@ -1382,8 +1811,8 @@ export class RulesClient { } if (apiKeyToInvalidate) { - await markApiKeyForInvalidation( - { apiKey: apiKeyToInvalidate }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: [apiKeyToInvalidate] }, this.logger, this.unsecuredSavedObjectsClient ); @@ -1484,8 +1913,8 @@ export class RulesClient { await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version }); } catch (e) { // Avoid unused API key - markApiKeyForInvalidation( - { apiKey: updateAttributes.apiKey }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: updateAttributes.apiKey ? [updateAttributes.apiKey] : [] }, this.logger, this.unsecuredSavedObjectsClient ); @@ -1502,8 +1931,8 @@ export class RulesClient { scheduledTaskId: scheduledTask.id, }); if (apiKeyToInvalidate) { - await markApiKeyForInvalidation( - { apiKey: apiKeyToInvalidate }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: [apiKeyToInvalidate] }, this.logger, this.unsecuredSavedObjectsClient ); @@ -1642,8 +2071,8 @@ export class RulesClient { ? this.taskManager.removeIfExists(attributes.scheduledTaskId) : null, apiKeyToInvalidate - ? await markApiKeyForInvalidation( - { apiKey: apiKeyToInvalidate }, + ? await bulkMarkApiKeysForInvalidation( + { apiKeys: [apiKeyToInvalidate] }, this.logger, this.unsecuredSavedObjectsClient ) diff --git a/x-pack/plugins/alerting/server/rules_client/tests/bulk_edit.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/bulk_edit.test.ts new file mode 100644 index 00000000000000..e878fd3f79e176 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/tests/bulk_edit.test.ts @@ -0,0 +1,902 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { RulesClient, ConstructorOptions } from '../rules_client'; +import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; +import { RecoveredActionGroup, RuleTypeParams } from '../../../common'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { AlertingAuthorization } from '../../authorization/alerting_authorization'; +import { ActionsAuthorization, ActionsClient } from '@kbn/actions-plugin/server'; +import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; +import { getBeforeSetup, setGlobalDate } from './lib'; +import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; + +jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ + bulkMarkApiKeysForInvalidation: jest.fn(), +})); + +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const kibanaVersion = 'v8.2.0'; +const createAPIKeyMock = jest.fn(); +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: createAPIKeyMock, + logger: loggingSystemMock.create().get(), + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + minimumScheduleInterval: { value: '1m', enforce: false }, +}; + +beforeEach(() => { + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); +}); + +setGlobalDate(); + +describe('bulkEdit()', () => { + let rulesClient: RulesClient; + let actionsClient: jest.Mocked; + const existingRule = { + id: '1', + type: 'alert', + attributes: { + enabled: false, + tags: ['foo'], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: {}, + throttle: null, + notifyWhen: null, + actions: [], + name: 'my rule name', + }, + references: [], + version: '123', + }; + const existingDecryptedRule = { + ...existingRule, + attributes: { + ...existingRule.attributes, + apiKey: Buffer.from('123:abc').toString('base64'), + }, + }; + + const mockCreatePointInTimeFinderAsInternalUser = ( + response = { saved_objects: [existingDecryptedRule] } + ) => { + encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest + .fn() + .mockResolvedValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield response; + }, + }); + }; + + beforeEach(async () => { + rulesClient = new RulesClient(rulesClientParams); + rulesClientParams.getActionsClient.mockResolvedValue(actionsClient); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + ensureRuleTypeIsAuthorized() {}, + }); + + unsecuredSavedObjectsClient.find.mockResolvedValue({ + aggregations: { + alertTypeId: { + buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 1 }], + }, + }, + saved_objects: [], + per_page: 0, + page: 0, + total: 1, + }); + + mockCreatePointInTimeFinderAsInternalUser(); + + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [existingRule], + }); + + ruleTypeRegistry.get.mockReturnValue({ + id: 'myType', + name: 'Test', + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'custom', name: 'Not the Default' }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + async executor() {}, + producer: 'alerts', + }); + }); + describe('tags operations', () => { + test('should add new tag', async () => { + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + tags: ['foo', 'test-1'], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: {}, + throttle: null, + notifyWhen: null, + actions: [], + }, + references: [], + version: '123', + }, + ], + }); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(result.total).toBe(1); + expect(result.errors).toHaveLength(0); + expect(result.rules).toHaveLength(1); + expect(result.rules[0]).toHaveProperty('tags', ['foo', 'test-1']); + + expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + id: '1', + type: 'alert', + attributes: expect.objectContaining({ + tags: ['foo', 'test-1'], + }), + }), + ]); + }); + + test('should delete tag', async () => { + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + tags: [], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: {}, + throttle: null, + notifyWhen: null, + actions: [], + }, + references: [], + version: '123', + }, + ], + }); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'tags', + operation: 'delete', + value: ['foo'], + }, + ], + }); + + expect(result.rules[0]).toHaveProperty('tags', []); + + expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + id: '1', + type: 'alert', + attributes: expect.objectContaining({ + tags: [], + }), + }), + ]); + }); + + test('should set tags', async () => { + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + tags: ['test-1', 'test-2'], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: {}, + throttle: null, + notifyWhen: null, + actions: [], + }, + references: [], + version: '123', + }, + ], + }); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'tags', + operation: 'set', + value: ['test-1', 'test-2'], + }, + ], + }); + + expect(result.rules[0]).toHaveProperty('tags', ['test-1', 'test-2']); + + expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + id: '1', + type: 'alert', + attributes: expect.objectContaining({ + tags: ['test-1', 'test-2'], + }), + }), + ]); + }); + }); + + describe('ruleTypes aggregation and validation', () => { + test('should call unsecuredSavedObjectsClient.find for aggregations by alertTypeId and consumer', async () => { + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + aggs: { + alertTypeId: { + multi_terms: { + terms: [ + { + field: 'alert.attributes.alertTypeId', + }, + { + field: 'alert.attributes.consumer', + }, + ], + }, + }, + }, + filter: { + arguments: [ + { + type: 'literal', + value: 'alert.attributes.tags', + }, + { + type: 'literal', + value: 'APM', + }, + { + type: 'literal', + value: true, + }, + ], + function: 'is', + type: 'function', + }, + page: 1, + perPage: 0, + type: 'alert', + }); + }); + test('should call unsecuredSavedObjectsClient.find for aggregations when called with ids options', async () => { + await rulesClient.bulkEdit({ + ids: ['2', '3'], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + aggs: { + alertTypeId: { + multi_terms: { + terms: [ + { + field: 'alert.attributes.alertTypeId', + }, + { + field: 'alert.attributes.consumer', + }, + ], + }, + }, + }, + filter: { + arguments: [ + { + arguments: [ + { + type: 'literal', + value: 'alert.id', + }, + { + type: 'literal', + value: 'alert:2', + }, + { + type: 'literal', + value: false, + }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { + type: 'literal', + value: 'alert.id', + }, + { + type: 'literal', + value: 'alert:3', + }, + { + type: 'literal', + value: false, + }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'or', + type: 'function', + }, + page: 1, + perPage: 0, + type: 'alert', + }); + }); + test('should throw if number of matched rules greater than 10_000', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + aggregations: { + alertTypeId: { + buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 1 }], + }, + }, + saved_objects: [], + per_page: 0, + page: 0, + total: 10001, + }); + + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow('More than 10000 rules matched for bulk edit'); + }); + + test('should throw if aggregations result is invalid', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + aggregations: { + alertTypeId: {}, + }, + saved_objects: [], + per_page: 0, + page: 0, + total: 0, + }); + + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow('No rules found for bulk edit'); + }); + + test('should throw if ruleType is not enabled', async () => { + ruleTypeRegistry.ensureRuleTypeEnabled.mockImplementation(() => { + throw new Error('Not enabled'); + }); + + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow('Not enabled'); + + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenLastCalledWith('myType'); + }); + + test('should throw if ruleType is not authorized', async () => { + authorization.ensureAuthorized.mockImplementation(() => { + throw new Error('Unauthorized'); + }); + + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow('Unauthorized'); + + expect(authorization.ensureAuthorized).toHaveBeenLastCalledWith({ + consumer: 'myApp', + entity: 'rule', + operation: 'bulkEdit', + ruleTypeId: 'myType', + }); + }); + }); + + describe('apiKeys', () => { + test('should call createPointInTimeFinderDecryptedAsInternalUser that returns api Keys', async () => { + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect( + encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser + ).toHaveBeenCalledWith({ + filter: { + arguments: [ + { + type: 'literal', + value: 'alert.attributes.tags', + }, + { + type: 'literal', + value: 'APM', + }, + { + type: 'literal', + value: true, + }, + ], + function: 'is', + type: 'function', + }, + perPage: 100, + type: 'alert', + namespaces: ['default'], + }); + }); + + test('should call bulkMarkApiKeysForInvalidation with keys apiKeys to invalidate', async () => { + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); + }); + + test('should call bulkMarkApiKeysForInvalidation to invalidate unused keys if bulkUpdate failed', async () => { + createAPIKeyMock.mockReturnValue({ apiKeysEnabled: true, result: { api_key: '111' } }); + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...existingDecryptedRule, + attributes: { ...existingDecryptedRule.attributes, enabled: true }, + }, + ], + }); + + unsecuredSavedObjectsClient.bulkUpdate.mockImplementation(() => { + throw new Error('Fail'); + }); + + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow('Fail'); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['dW5kZWZpbmVkOjExMQ=='] }, + expect.any(Object), + expect.any(Object) + ); + }); + + test('should call bulkMarkApiKeysForInvalidation to invalidate unused keys if SO update failed', async () => { + createAPIKeyMock.mockReturnValue({ apiKeysEnabled: true, result: { api_key: '111' } }); + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...existingDecryptedRule, + attributes: { ...existingDecryptedRule.attributes, enabled: true }, + }, + ], + }); + + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + tags: ['foo'], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: { index: ['test-index-*'] }, + throttle: null, + notifyWhen: null, + actions: [], + }, + references: [], + version: '123', + error: { + error: 'test failure', + statusCode: 500, + message: 'test failure', + }, + }, + ], + }); + + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['dW5kZWZpbmVkOjExMQ=='] }, + expect.any(Object), + expect.any(Object) + ); + }); + + test('should not call create apiKey if rule is disabled', async () => { + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + expect(rulesClientParams.createAPIKey).not.toHaveBeenCalledWith(); + }); + + test('should return error in rule errors if key is not generated', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...existingDecryptedRule, + attributes: { ...existingDecryptedRule.attributes, enabled: true }, + }, + ], + }); + + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + expect(rulesClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: myType/my rule name'); + }); + }); + + describe('params validation', () => { + test('should return error for rule that failed params validation', async () => { + ruleTypeRegistry.get.mockReturnValue({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + validate: { + params: schema.object({ + param1: schema.string(), + }), + }, + async executor() {}, + producer: 'alerts', + }); + + const result = await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(result.errors).toHaveLength(1); + + expect(result.errors[0]).toHaveProperty( + 'message', + 'params invalid: [param1]: expected value of type [string] but got [undefined]' + ); + expect(result.errors[0]).toHaveProperty('rule.id', '1'); + expect(result.errors[0]).toHaveProperty('rule.name', 'my rule name'); + }); + + test('should validate mutatedParams for rules', async () => { + ruleTypeRegistry.get.mockReturnValue({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + validate: { + params: { + validate: (rule) => rule as RuleTypeParams, + validateMutatedParams: (rule: unknown) => { + throw Error('Mutated error for rule'); + }, + }, + }, + async executor() {}, + producer: 'alerts', + }); + + const result = await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(result.errors).toHaveLength(1); + + expect(result.errors[0]).toHaveProperty( + 'message', + 'Mutated params invalid: Mutated error for rule' + ); + expect(result.errors[0]).toHaveProperty('rule.id', '1'); + expect(result.errors[0]).toHaveProperty('rule.name', 'my rule name'); + }); + }); + + describe('attributes validation', () => { + test('should not update saved object and return error if SO has interval less than minimum configured one when enforce = true', async () => { + rulesClient = new RulesClient({ + ...rulesClientParams, + minimumScheduleInterval: { value: '3m', enforce: true }, + }); + + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [], + }); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [], + paramsModifier: async (params) => { + params.index = ['test-index-*']; + + return params; + }, + }); + + expect(result.errors).toHaveLength(1); + expect(result.rules).toHaveLength(0); + expect(result.errors[0].message).toBe( + 'Error updating rule: the interval is less than the allowed minimum interval of 3m' + ); + }); + }); + + describe('paramsModifier', () => { + test('should update index pattern params', async () => { + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + tags: ['foo'], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: { index: ['test-index-*'] }, + throttle: null, + notifyWhen: null, + actions: [], + }, + references: [], + version: '123', + }, + ], + }); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [], + paramsModifier: async (params) => { + params.index = ['test-index-*']; + + return params; + }, + }); + + expect(result.errors).toHaveLength(0); + expect(result.rules).toHaveLength(1); + expect(result.rules[0]).toHaveProperty('params.index', ['test-index-*']); + + expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + id: '1', + type: 'alert', + attributes: expect.objectContaining({ + params: expect.objectContaining({ + index: ['test-index-*'], + }), + }), + }), + ]); + }); + }); + + describe('method input validation', () => { + test('should throw error when both ids and filter supplied in method call', async () => { + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + ids: ['1', '2'], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow( + "Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method arguments" + ); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index c8f10c4e686f00..8e24b7c1832628 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -20,6 +20,11 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; import { getDefaultRuleMonitoring } from '../../task_runner/task_runner'; +import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; + +jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ + bulkMarkApiKeysForInvalidation: jest.fn(), +})); jest.mock('@kbn/core/server/saved_objects/service/lib/utils', () => ({ SavedObjectsUtils: { @@ -2119,25 +2124,17 @@ describe('create()', () => { result: { id: '123', name: '123', api_key: 'abc' }, }); unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Test failure')); - const createdAt = new Date().toISOString(); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt, - }, - references: [], - }); await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( `"Test failure"` ); expect(taskManager.schedule).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2); - expect(unsecuredSavedObjectsClient.create.mock.calls[1][1]).toStrictEqual({ - apiKeyId: '123', - createdAt, - }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); }); test('fails if task scheduling fails due to conflict', async () => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts index f6194ec6c1a5ff..6b45c16bcd6542 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts @@ -16,6 +16,11 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup } from './lib'; +import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; + +jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ + bulkMarkApiKeysForInvalidation: jest.fn(), +})); const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -100,21 +105,15 @@ describe('delete()', () => { }); test('successfully removes an alert', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const result = await rulesClient.delete({ id: '1' }); expect(result).toEqual({ success: true }); expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith('alert', '1'); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe( - 'api_key_pending_invalidation' + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) ); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', @@ -124,15 +123,6 @@ describe('delete()', () => { test('falls back to SOC.get when getDecryptedAsInternalUser throws an error', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const result = await rulesClient.delete({ id: '1' }); expect(result).toEqual({ success: true }); @@ -159,15 +149,6 @@ describe('delete()', () => { }); test(`doesn't invalidate API key when apiKey is null`, async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({ ...existingAlert, attributes: { @@ -183,24 +164,15 @@ describe('delete()', () => { test('swallows error when invalidate API key throws', async () => { unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); await rulesClient.delete({ id: '1' }); - expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe( - 'api_key_pending_invalidation' - ); - expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail' + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) ); }); test('swallows error when getDecryptedAsInternalUser throws an error', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); await rulesClient.delete({ id: '1' }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts index f15b647a8e3967..02f2c66a491ad6 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts @@ -14,11 +14,15 @@ import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/s import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { AlertingAuthorization } from '../../authorization/alerting_authorization'; import { ActionsAuthorization } from '@kbn/actions-plugin/server'; -import { InvalidatePendingApiKey } from '../../types'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; +import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; + +jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ + bulkMarkApiKeysForInvalidation: jest.fn(), +})); jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -188,15 +192,6 @@ describe('disable()', () => { }); test('disables an alert', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { @@ -235,21 +230,15 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('123'); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); }); test('disables the rule with calling event log to "recover" the alert instances from the task state', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const scheduledTaskId = 'task-123'; taskManager.get.mockResolvedValue({ id: scheduledTaskId, @@ -317,9 +306,12 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('123'); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent.mock.calls[0][0]).toStrictEqual({ @@ -362,15 +354,6 @@ describe('disable()', () => { }); test('disables the rule even if unable to retrieve task manager doc to generate recovery event log events', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); taskManager.get.mockRejectedValueOnce(new Error('Fail')); await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); @@ -410,9 +393,12 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('123'); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); expect(eventLogger.logEvent).toHaveBeenCalledTimes(0); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( @@ -422,15 +408,6 @@ describe('disable()', () => { test('falls back when getDecryptedAsInternalUser throws an error', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); @@ -483,16 +460,6 @@ describe('disable()', () => { }, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); - await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); expect(taskManager.removeIfExists).not.toHaveBeenCalled(); @@ -500,15 +467,6 @@ describe('disable()', () => { }); test(`doesn't invalidate when no API key is used`, async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce(existingAlert); await rulesClient.disable({ id: '1' }); @@ -516,15 +474,6 @@ describe('disable()', () => { }); test('swallows error when failing to load decrypted saved object', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); await rulesClient.disable({ id: '1' }); @@ -547,8 +496,11 @@ describe('disable()', () => { test('swallows error when invalidate API key throws', async () => { unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); await rulesClient.disable({ id: '1' }); - expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail' + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) ); }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index 0a4737006d5574..d823e0aaafdb87 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -16,8 +16,12 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; -import { InvalidatePendingApiKey } from '../../types'; import { getBeforeSetup, setGlobalDate } from './lib'; +import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; + +jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ + bulkMarkApiKeysForInvalidation: jest.fn(), +})); const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -183,7 +187,6 @@ describe('enable()', () => { }); test('enables a rule', async () => { - const createdAt = new Date().toISOString(); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ ...existingAlert, attributes: { @@ -194,22 +197,12 @@ describe('enable()', () => { updatedBy: 'elastic', }, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt, - }, - references: [], - }); await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', @@ -273,7 +266,6 @@ describe('enable()', () => { }); test('invalidates API key if ever one existed prior to updating', async () => { - const createdAt = new Date().toISOString(); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({ ...existingAlert, attributes: { @@ -281,24 +273,18 @@ describe('enable()', () => { apiKey: Buffer.from('123:abc').toString('base64'), }, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt, - }, - references: [], - }); await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('123'); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); }); test(`doesn't enable already enabled alerts`, async () => { @@ -399,31 +385,24 @@ describe('enable()', () => { }); test('throws error when failing to update the first time', async () => { - const createdAt = new Date().toISOString(); rulesClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, result: { id: '123', name: '123', api_key: 'abc' }, }); unsecuredSavedObjectsClient.update.mockReset(); unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail to update')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt, - }, - references: [], - }); await expect(rulesClient.enable({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Fail to update"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('123'); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); expect(taskManager.schedule).not.toHaveBeenCalled(); }); @@ -462,7 +441,6 @@ describe('enable()', () => { }); test('enables a rule if conflict errors received when scheduling a task', async () => { - const createdAt = new Date().toISOString(); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ ...existingAlert, attributes: { @@ -473,15 +451,6 @@ describe('enable()', () => { updatedBy: 'elastic', }, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt, - }, - references: [], - }); taskManager.schedule.mockRejectedValueOnce( Object.assign(new Error('Conflict!'), { statusCode: 409 }) ); @@ -491,7 +460,6 @@ describe('enable()', () => { expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index 573ae98ba49f0a..1508d49fe58517 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -12,7 +12,7 @@ import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mock import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; -import { IntervalSchedule, InvalidatePendingApiKey } from '../../types'; +import { IntervalSchedule } from '../../types'; import { RecoveredActionGroup } from '../../../common'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; @@ -22,6 +22,7 @@ import { ActionsAuthorization, ActionsClient } from '@kbn/actions-plugin/server' import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; +import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; jest.mock('@kbn/core/server/saved_objects/service/lib/utils', () => ({ SavedObjectsUtils: { @@ -29,6 +30,11 @@ jest.mock('@kbn/core/server/saved_objects/service/lib/utils', () => ({ }, })); +jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ + bulkMarkApiKeysForInvalidation: jest.fn(), +})); + +const bulkMarkApiKeysForInvalidationMock = bulkMarkApiKeysForInvalidation as jest.Mock; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); @@ -238,15 +244,6 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const result = await rulesClient.update({ id: '1', data: { @@ -331,7 +328,8 @@ describe('update()', () => { namespace: 'default', }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledTimes(1); expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` @@ -875,24 +873,6 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const result = await rulesClient.update({ id: '1', data: { @@ -942,7 +922,15 @@ describe('update()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith( + { + apiKeys: ['MTIzOmFiYw=='], + }, + expect.any(Object), + expect.any(Object) + ); expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` @@ -1040,15 +1028,6 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const result = await rulesClient.update({ id: '1', data: { @@ -1099,7 +1078,7 @@ describe('update()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` @@ -1322,7 +1301,7 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); // add ApiKey to invalidate + bulkMarkApiKeysForInvalidationMock.mockImplementationOnce(() => new Error('Fail')); await rulesClient.update({ id: '1', data: { @@ -1345,8 +1324,12 @@ describe('update()', () => { ], }, }); - expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail' + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith( + { + apiKeys: ['MTIzOmFiYw=='], + }, + expect.any(Object), + expect.any(Object) ); }); @@ -1516,9 +1499,14 @@ describe('update()', () => { }, }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[1][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('234'); + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith( + { + apiKeys: ['MjM0OmFiYw=='], + }, + expect.any(Object), + expect.any(Object) + ); }); describe('updating an alert schedule', () => { @@ -1913,15 +1901,6 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await rulesClient.update({ id: '1', data: { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts index abc0db48ac167b..e2841ba4927c62 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts @@ -15,9 +15,14 @@ import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { AlertingAuthorization } from '../../authorization/alerting_authorization'; import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; -import { InvalidatePendingApiKey } from '../../types'; import { getBeforeSetup, setGlobalDate } from './lib'; +import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; +jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ + bulkMarkApiKeysForInvalidation: jest.fn(), +})); + +const bulkMarkApiKeysForInvalidationMock = bulkMarkApiKeysForInvalidation as jest.Mock; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); @@ -89,15 +94,6 @@ describe('updateApiKey()', () => { beforeEach(() => { rulesClient = new RulesClient(rulesClientParams); unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingEncryptedAlert); }); @@ -140,8 +136,11 @@ describe('updateApiKey()', () => { }, { version: '123' } ); - expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe( - 'api_key_pending_invalidation' + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) ); }); @@ -162,15 +161,6 @@ describe('updateApiKey()', () => { result: { id: '234', name: '123', api_key: 'abc' }, }); encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await rulesClient.updateApiKey({ id: '1' }); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); @@ -210,33 +200,26 @@ describe('updateApiKey()', () => { }); test('swallows error when invalidate API key throws', async () => { - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); + bulkMarkApiKeysForInvalidationMock.mockImplementationOnce(() => new Error('Fail')); await rulesClient.updateApiKey({ id: '1' }); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe( - 'api_key_pending_invalidation' + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) ); }); test('swallows error when getting decrypted object throws', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await rulesClient.updateApiKey({ id: '1' }); expect(rulesClientParams.logger.error).toHaveBeenCalledWith( 'updateApiKey(): Failed to load API key to invalidate on alert 1: Fail' ); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test('throws when unsecuredSavedObjectsClient update fails and invalidates newly created API key', async () => { @@ -245,22 +228,16 @@ describe('updateApiKey()', () => { result: { id: '234', name: '234', api_key: 'abc' }, }); unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await expect(rulesClient.updateApiKey({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Fail"` ); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('234'); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MjM0OmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); }); describe('authorization', () => { diff --git a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts index 3732c79075f97e..753015aa02ea51 100644 --- a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts @@ -116,7 +116,7 @@ async function update(success: boolean) { expect(logger.warn).lastCalledWith(`rulesClient.update('alert-id') conflict, exceeded retries`); return expectConflict(success, err, 'create'); } - expectSuccess(success, 3, 'create'); + expectSuccess(success, 2, 'create'); // only checking the debug messages in this test expect(logger.debug).nthCalledWith(1, `rulesClient.update('alert-id') conflict, retrying ...`); diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 4a2290d0bde33c..1c453df386e24c 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -43,7 +43,7 @@ import { } from '../common'; export type WithoutQueryAndParams = Pick>; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; - +export type { RuleTypeParams }; /** * @public */ @@ -123,6 +123,7 @@ export type ExecutorType< export interface RuleTypeParamsValidator { validate: (object: unknown) => Params; + validateMutatedParams?: (mutatedOject: unknown, origObject?: unknown) => Params; } export interface RuleType< diff --git a/x-pack/plugins/encrypted_saved_objects/README.md b/x-pack/plugins/encrypted_saved_objects/README.md index 41e2dce75da157..dac76f51cbc759 100644 --- a/x-pack/plugins/encrypted_saved_objects/README.md +++ b/x-pack/plugins/encrypted_saved_objects/README.md @@ -99,6 +99,21 @@ const savedObjectWithDecryptedContent = await esoClient.getDecryptedAsInternalU one would pass to `SavedObjectsClient.get`. These argument allows to specify `namespace` property that, for example, is required if Saved Object was created within a non-default space. +Alternative option is using `createPointInTimeFinderDecryptedAsInternalUser` API method, that can be used to help page through large sets of saved objects. +Its interface matches interface of the corresponding Saved Objects API `createPointInTimeFinder` method: + +```typescript +const finder = await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser({ + filter, + type: 'my-saved-object-type', + perPage: 1000, +}); + +for await (const response of finder.find()) { + // process response +} +``` + ### Defining migrations EncryptedSavedObjects rely on standard SavedObject migrations, but due to the additional complexity introduced by the need to decrypt and reencrypt the migrated document, there are some caveats to how we support this. The good news is, most of this complexity is abstracted away by the plugin and all you need to do is leverage our api. diff --git a/x-pack/plugins/encrypted_saved_objects/server/mocks.ts b/x-pack/plugins/encrypted_saved_objects/server/mocks.ts index 5f80e5bab310ff..aa72fb372e8786 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/mocks.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/mocks.ts @@ -25,18 +25,19 @@ function createEncryptedSavedObjectsSetupMock( function createEncryptedSavedObjectsStartMock() { return { isEncryptionError: jest.fn(), - getClient: jest.fn((opts) => createEncryptedSavedObjectsClienttMock(opts)), + getClient: jest.fn((opts) => createEncryptedSavedObjectsClientMock(opts)), } as jest.Mocked; } -function createEncryptedSavedObjectsClienttMock(opts?: EncryptedSavedObjectsClientOptions) { +function createEncryptedSavedObjectsClientMock(opts?: EncryptedSavedObjectsClientOptions) { return { getDecryptedAsInternalUser: jest.fn(), + createPointInTimeFinderDecryptedAsInternalUser: jest.fn(), } as jest.Mocked; } export const encryptedSavedObjectsMock = { createSetup: createEncryptedSavedObjectsSetupMock, createStart: createEncryptedSavedObjectsStartMock, - createClient: createEncryptedSavedObjectsClienttMock, + createClient: createEncryptedSavedObjectsClientMock, }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts index f4708e182ad312..970f3baed7ab1a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts @@ -60,10 +60,11 @@ describe('EncryptedSavedObjects Plugin', () => { `); expect(startContract.getClient()).toMatchInlineSnapshot(` - Object { - "getDecryptedAsInternalUser": [Function], - } - `); + Object { + "createPointInTimeFinderDecryptedAsInternalUser": [Function], + "getDecryptedAsInternalUser": [Function], + } + `); }); }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts index 032842e0047c0f..b93141a3ad9898 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts @@ -166,5 +166,171 @@ describe('#setupSavedObjects', () => { { namespace: 'some-ns' } ); }); + + it('does not call decryptAttributes if Saved Object type is not registered', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'not-known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.get.mockResolvedValue(mockSavedObject); + + await expect( + setupContract().getDecryptedAsInternalUser(mockSavedObject.type, mockSavedObject.id, { + namespace: 'some-ns', + }) + ).resolves.toEqual(mockSavedObject); + + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(0); + }); + }); + + describe('#createPointInTimeFinderDecryptedAsInternalUser', () => { + it('includes `namespace` for single-namespace saved objects', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { saved_objects: [mockSavedObject] }; + }, + }); + + mockSavedObjectTypeRegistry.isSingleNamespace.mockReturnValue(true); + + const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({ + type: 'known-type', + namespaces: ['some-ns'], + }); + + for await (const res of finder.find()) { + expect(res).toEqual({ + saved_objects: [ + { + ...mockSavedObject, + attributes: { attrOne: 'one', attrSecret: 'secret' }, + }, + ], + }); + } + + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledWith( + { type: mockSavedObject.type, id: mockSavedObject.id, namespace: 'some-ns' }, + mockSavedObject.attributes + ); + + expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledWith( + { type: 'known-type', namespaces: ['some-ns'] }, + undefined + ); + }); + + it('does not include `namespace` for multiple-namespace saved objects', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { saved_objects: [mockSavedObject] }; + }, + }); + + mockSavedObjectTypeRegistry.isSingleNamespace.mockReturnValue(false); + + const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({ + type: 'known-type', + namespaces: ['some-ns'], + }); + + for await (const res of finder.find()) { + expect(res).toEqual({ + saved_objects: [ + { + ...mockSavedObject, + attributes: { attrOne: 'one', attrSecret: 'secret' }, + }, + ], + }); + } + + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledWith( + { type: mockSavedObject.type, id: mockSavedObject.id, namespace: undefined }, + mockSavedObject.attributes + ); + + expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledWith( + { type: 'known-type', namespaces: ['some-ns'] }, + undefined + ); + }); + + it('does not call decryptAttributes if Saved Object type is not registered', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'not-known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { saved_objects: [mockSavedObject] }; + }, + }); + + const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({ + type: 'not-known-type', + namespaces: ['some-ns'], + }); + + for await (const res of finder.find()) { + expect(res).toEqual({ + saved_objects: [mockSavedObject], + }); + } + + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(0); + }); + + it('returns error within Saved Object if decryption failed', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { saved_objects: [mockSavedObject] }; + }, + }); + + mockEncryptedSavedObjectsService.decryptAttributes.mockImplementation(() => { + throw new Error('Test failure'); + }); + + const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({ + type: 'known-type', + namespaces: ['some-ns'], + }); + + for await (const res of finder.find()) { + expect(res.saved_objects[0].error).toHaveProperty('message', 'Test failure'); + } + }); }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index 3d9d36206b5c92..e2b58e3003d969 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -5,11 +5,16 @@ * 2.0. */ +import pMap from 'p-map'; + import type { + ISavedObjectsPointInTimeFinder, ISavedObjectsRepository, ISavedObjectTypeRegistry, SavedObject, SavedObjectsBaseOptions, + SavedObjectsCreatePointInTimeFinderDependencies, + SavedObjectsCreatePointInTimeFinderOptions, SavedObjectsServiceSetup, StartServicesAccessor, } from '@kbn/core/server'; @@ -43,6 +48,31 @@ export interface EncryptedSavedObjectsClient { id: string, options?: SavedObjectsBaseOptions ) => Promise>; + + /** + * API method, that can be used to help page through large sets of saved objects and returns decrypted properties in result SO. + * Its interface matches interface of the corresponding Saved Objects API `createPointInTimeFinder` method: + * + * @example + * ```ts + * const finder = await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser({ + * filter, + * type: 'my-saved-object-type', + * perPage: 1000, + * }); + * for await (const response of finder.find()) { + * // process response + * } + * ``` + * + * @param findOptions matches interface of corresponding argument of Saved Objects API `createPointInTimeFinder` {@link SavedObjectsCreatePointInTimeFinderOptions} + * @param dependencies matches interface of corresponding argument of Saved Objects API `createPointInTimeFinder` {@link SavedObjectsCreatePointInTimeFinderDependencies} + * + */ + createPointInTimeFinderDecryptedAsInternalUser( + findOptions: SavedObjectsCreatePointInTimeFinderOptions, + dependencies?: SavedObjectsCreatePointInTimeFinderDependencies + ): Promise>; } export function setupSavedObjects({ @@ -84,6 +114,11 @@ export function setupSavedObjects({ ): Promise> => { const [internalRepository, typeRegistry] = await internalRepositoryAndTypeRegistryPromise; const savedObject = await internalRepository.get(type, id, options); + + if (!service.isRegistered(savedObject.type)) { + return savedObject as SavedObject; + } + return { ...savedObject, attributes: (await service.decryptAttributes( @@ -96,6 +131,61 @@ export function setupSavedObjects({ )) as T, }; }, + + createPointInTimeFinderDecryptedAsInternalUser: async ( + findOptions: SavedObjectsCreatePointInTimeFinderOptions, + dependencies?: SavedObjectsCreatePointInTimeFinderDependencies + ): Promise> => { + const [internalRepository, typeRegistry] = await internalRepositoryAndTypeRegistryPromise; + const finder = internalRepository.createPointInTimeFinder(findOptions, dependencies); + const finderAsyncGenerator = finder.find(); + + async function* encryptedFinder() { + for await (const res of finderAsyncGenerator) { + const encryptedSavedObjects = await pMap( + res.saved_objects, + async (savedObject) => { + if (!service.isRegistered(savedObject.type)) { + return savedObject; + } + + const descriptor = { + type: savedObject.type, + id: savedObject.id, + namespace: getDescriptorNamespace( + typeRegistry, + savedObject.type, + findOptions.namespaces + ), + }; + + try { + return { + ...savedObject, + attributes: (await service.decryptAttributes( + descriptor, + savedObject.attributes as Record + )) as T, + }; + } catch (error) { + // catch error and enrich SO with it, return stripped attributes. Then consumer of API can decide either proceed + // with only unsecured properties or stop when error happens + const { attributes: strippedAttrs } = await service.stripOrDecryptAttributes( + descriptor, + savedObject.attributes as Record + ); + return { ...savedObject, attributes: strippedAttrs as T, error }; + } + }, + { concurrency: 50 } + ); + + yield { ...res, saved_objects: encryptedSavedObjects }; + } + } + + return { ...finder, find: () => encryptedFinder() }; + }, }; }; } diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index c4da245f48fcda..8272c9220e103d 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -229,6 +229,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkEdit", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze", ] `); @@ -326,6 +327,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkEdit", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/get", "alerting:1.0.0-zeta1:alert-type/my-feature/alert/find", @@ -383,6 +385,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkEdit", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", @@ -488,6 +491,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/rule/muteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze", + "alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkEdit", "alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState", diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts index 3a692e935cf371..542dfd1267d4cc 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -34,6 +34,7 @@ const writeOperations: Record = { 'muteAlert', 'unmuteAlert', 'snooze', + 'bulkEdit', 'unsnooze', ], alert: ['update'], diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/bulk_edit.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/bulk_edit.ts new file mode 100644 index 00000000000000..6eeafe84724992 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/bulk_edit.ts @@ -0,0 +1,619 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import type { SanitizedRule } from '@kbn/alerting-plugin/common'; +import { UserAtSpaceScenarios } from '../../../scenarios'; +import { + checkAAD, + getUrlPrefix, + getTestRuleData, + ObjectRemover, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, +} from '../../../../common/lib'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createUpdateTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + describe('bulkEdit', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle bulk edit of rules appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/connector`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + connector_type_id: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ tags: ['foo'] })) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const payload = { + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'actions', + value: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }, + ], + }; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(payload); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.body).to.eql({ + error: 'Forbidden', + message: 'Unauthorized to find rules for any rule types', + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'global_read at space1': + expect(response.body).to.eql({ + error: 'Forbidden', + message: 'Unauthorized to bulkEdit a "test.noop" rule for "alertsFixture"', + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.body).to.eql({ + errors: [ + { + message: 'Unauthorized to get actions', + rule: { + id: createdRule.id, + name: 'abc', + }, + }, + ], + rules: [], + total: 1, + }); + expect(response.statusCode).to.eql(200); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.body.rules[0].actions).to.eql([ + { + id: createdAction.id, + group: 'default', + params: {}, + connector_type_id: 'test.noop', + }, + ]); + expect(response.statusCode).to.eql(200); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdRule.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle bulk edit of multiple rules appropriately', async () => { + const rules = await Promise.all( + Array.from({ length: 10 }).map(() => + supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ tags: [`multiple-rules-edit-${scenario.id}`] })) + .expect(200) + ) + ); + + rules.forEach(({ body: rule }) => { + objectRemover.add(space.id, rule.id, 'rule', 'alerting'); + }); + + const payload = { + filter: `alert.attributes.tags: "multiple-rules-edit-${scenario.id}"`, + operations: [ + { + operation: 'add', + field: 'tags', + value: ['tag-A'], + }, + ], + }; + + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(payload); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.body).to.eql({ + error: 'Forbidden', + message: 'Unauthorized to find rules for any rule types', + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'global_read at space1': + expect(response.body).to.eql({ + error: 'Forbidden', + message: 'Unauthorized to bulkEdit a "test.noop" rule for "alertsFixture"', + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + response.body.rules.forEach((rule: SanitizedRule) => + expect(rule.tags).to.eql([`multiple-rules-edit-${scenario.id}`, 'tag-A']) + ); + expect(response.body.rules).to.have.length(10); + expect(response.body.errors).to.have.length(0); + expect(response.body.total).to.be(10); + + expect(response.statusCode).to.eql(200); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle bulk edit of rules appropriately when consumer is the same as producer', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + tags: ['foo'], + rule_type_id: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const payload = { + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['tag-A', 'tag-B'], + }, + ], + }; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(payload); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.body).to.eql({ + error: 'Forbidden', + message: 'Unauthorized to find rules for any rule types', + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.body).to.eql({ errors: [], rules: [], total: 0 }); + expect(response.statusCode).to.eql(200); + break; + case 'global_read at space1': + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'bulkEdit', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.body.rules[0].tags).to.eql(['foo', 'tag-A', 'tag-B']); + expect(response.statusCode).to.eql(200); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle bulk edit of rules request appropriately when consumer is not the producer', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const payload = { + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['tag-A', 'tag-B'], + }, + ], + }; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(payload); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.body).to.eql({ + error: 'Forbidden', + message: 'Unauthorized to find rules for any rule types', + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'global_read at space1': + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'bulkEdit', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'bulkEdit', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.body.rules[0].tags).to.eql(['foo', 'tag-A', 'tag-B']); + expect(response.statusCode).to.eql(200); + + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle bulk edit of rules request appropriately when consumer is "alerts"', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.restricted-noop', + consumer: 'alerts', + }) + ) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const payload = { + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['tag-A', 'tag-B'], + }, + ], + }; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(payload); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.body).to.eql({ + error: 'Forbidden', + message: 'Unauthorized to find rules for any rule types', + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.body).to.eql({ errors: [], rules: [], total: 0 }); + expect(response.statusCode).to.eql(200); + break; + case 'global_read at space1': + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'bulkEdit', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.body.rules[0].tags).to.eql(['foo', 'tag-A', 'tag-B']); + expect(response.statusCode).to.eql(200); + + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle bulk edit of rules when operation is invalid', async () => { + const payload = { + filter: '', + operations: [ + { + operation: 'invalid', + field: 'tags', + value: ['test'], + }, + ], + }; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(payload); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request body.operations.0]: types that failed validation:\n- [request body.operations.0.0.operation]: types that failed validation:\n - [request body.operations.0.operation.0]: expected value to equal [add]\n - [request body.operations.0.operation.1]: expected value to equal [delete]\n - [request body.operations.0.operation.2]: expected value to equal [set]\n- [request body.operations.0.1.operation]: types that failed validation:\n - [request body.operations.0.operation.0]: expected value to equal [add]\n - [request body.operations.0.operation.1]: expected value to equal [set]', + }); + expect(response.statusCode).to.eql(400); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle bulk edit of rules when operation field is invalid', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ tags: ['foo'] })) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const payload = { + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'test', + value: ['test'], + }, + ], + }; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(payload); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request body.operations.0]: types that failed validation:\n- [request body.operations.0.0.field]: expected value to equal [tags]\n- [request body.operations.0.1.field]: expected value to equal [actions]', + }); + expect(response.statusCode).to.eql(400); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle bulk edit of rules when operation field is invalid', async () => { + const payload = { + filter: '', + operations: [ + { + operation: 'add', + field: 'test', + value: ['test'], + }, + ], + }; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(payload); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request body.operations.0]: types that failed validation:\n- [request body.operations.0.0.field]: expected value to equal [tags]\n- [request body.operations.0.1.field]: expected value to equal [actions]', + }); + expect(response.statusCode).to.eql(400); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle bulk edit of rules when both ids and filter supplied in payload', async () => { + const payload = { + filter: 'test', + ids: ['test-id'], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['test'], + }, + ], + }; + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(payload); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.body).to.eql({ + error: 'Bad Request', + message: + "Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method arguments", + statusCode: 400, + }); + expect(response.statusCode).to.eql(400); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`shouldn't update rule from another space`, async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData()) + .expect(200); + objectRemover.add(space.id, createdRule.id, 'rule', 'alerting'); + + const response = await supertestWithoutAuth + .post(`${getUrlPrefix('other')}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({ + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['test'], + }, + ], + }); + + switch (scenario.id) { + case 'superuser at space1': + case 'global_read at space1': + expect(response.body).to.eql({ rules: [], errors: [], total: 0 }); + expect(response.statusCode).to.eql(200); + break; + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.body).to.eql({ + error: 'Forbidden', + message: 'Unauthorized to find rules for any rule types', + statusCode: 403, + }); + expect(response.statusCode).to.eql(403); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts index 6753b6383872db..a5c81a849d8f86 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group1/tests/alerting/index.ts @@ -30,6 +30,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./get_alert_state')); loadTestFile(require.resolve('./get_alert_summary')); loadTestFile(require.resolve('./rule_types')); + loadTestFile(require.resolve('./bulk_edit')); }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/bulk_edit.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/bulk_edit.ts new file mode 100644 index 00000000000000..ca99ebb7c23264 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/bulk_edit.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import type { SanitizedRule } from '@kbn/alerting-plugin/common'; +import { Spaces } from '../../scenarios'; +import { checkAAD, getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createUpdateTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('bulkEdit', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + it('should bulk edit rule with tags operation', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ tags: ['default'] })); + + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + const payload = { + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['tag-1'], + }, + ], + }; + + const bulkEditResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload); + + expect(bulkEditResponse.body.errors).to.have.length(0); + expect(bulkEditResponse.body.rules).to.have.length(1); + expect(bulkEditResponse.body.rules[0].tags).to.eql(['default', 'tag-1']); + + const { body: updatedRule } = await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdRule.id}`) + .set('kbn-xsrf', 'foo'); + + expect(updatedRule.tags).to.eql(['default', 'tag-1']); + + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: Spaces.space1.id, + type: 'alert', + id: createdRule.id, + }); + }); + + it('should bulk edit multiple rules with tags operation', async () => { + const rules: SanitizedRule[] = ( + await Promise.all( + Array.from({ length: 10 }).map(() => + supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ tags: [`multiple-rules-edit`] })) + .expect(200) + ) + ) + ).map((res) => res.body); + + rules.forEach((rule) => { + objectRemover.add(Spaces.space1.id, rule.id, 'rule', 'alerting'); + }); + + const payload = { + filter: `alert.attributes.tags: "multiple-rules-edit"`, + operations: [ + { + operation: 'set', + field: 'tags', + value: ['rewritten'], + }, + ], + }; + + const bulkEditResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload); + + expect(bulkEditResponse.body.total).to.be(10); + expect(bulkEditResponse.body.errors).to.have.length(0); + expect(bulkEditResponse.body.rules).to.have.length(10); + bulkEditResponse.body.rules.every((rule: { tags: string[] }) => + expect(rule.tags).to.eql([`rewritten`]) + ); + + const updatedRules: SanitizedRule[] = ( + await Promise.all( + rules.map((rule) => + supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${rule.id}`) + .set('kbn-xsrf', 'foo') + ) + ) + ).map((res) => res.body); + + updatedRules.forEach((rule) => { + expect(rule.tags).to.eql([`rewritten`]); + }); + }); + + it(`shouldn't bulk edit rule from another space`, async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send(getTestRuleData({ tags: ['default'] })); + + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + const payload = { + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['tag-1'], + }, + ], + }; + + await supertest + .post(`${getUrlPrefix(Spaces.other.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload) + .expect(200, { rules: [], errors: [], total: 0 }); + }); + + it('should return mapped params after bulk edit', async () => { + const { body: createdRule } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ tags: ['default'], params: { risk_score: 40, severity: 'medium' } }) + ); + + objectRemover.add(Spaces.space1.id, createdRule.id, 'rule', 'alerting'); + + const payload = { + ids: [createdRule.id], + operations: [ + { + operation: 'add', + field: 'tags', + value: ['tag-1'], + }, + ], + }; + + const bulkEditResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/_bulk_edit`) + .set('kbn-xsrf', 'foo') + .send(payload); + + expect(bulkEditResponse.body.errors).to.have.length(0); + expect(bulkEditResponse.body.rules).to.have.length(1); + expect(bulkEditResponse.body.rules[0].mapped_params).to.eql({ + risk_score: 40, + severity: '40-medium', + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index 4975207c023916..57a8ec1f8c0eed 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -45,6 +45,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC loadTestFile(require.resolve('./ephemeral')); loadTestFile(require.resolve('./event_log_alerts')); loadTestFile(require.resolve('./snooze')); + loadTestFile(require.resolve('./bulk_edit')); loadTestFile(require.resolve('./capped_action_type')); loadTestFile(require.resolve('./scheduled_task_id')); // Do not place test files here, due to https://github.com/elastic/kibana/issues/123059 diff --git a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/hidden_saved_object_routes.ts b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/hidden_saved_object_routes.ts index db90569310cbca..7b4a79aee2223c 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/hidden_saved_object_routes.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/hidden_saved_object_routes.ts @@ -6,7 +6,7 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter, CoreSetup } from '@kbn/core/server'; +import { IRouter, CoreSetup, SavedObject } from '@kbn/core/server'; import { PluginsSetup, PluginsStart } from '.'; export function registerHiddenSORoutes( @@ -42,6 +42,42 @@ export function registerHiddenSORoutes( } ); + router.get( + { + path: '/api/hidden_saved_objects/create-point-in-time-finder-decrypted-as-internal-user', + validate: { query: schema.object({ type: schema.string() }) }, + }, + async (context, request, response) => { + const [, { encryptedSavedObjects }] = await core.getStartServices(); + const spaceId = deps.spaces.spacesService.getSpaceId(request); + const namespace = deps.spaces.spacesService.spaceIdToNamespace(spaceId); + + const { type } = request.query; + + let savedObjects: SavedObject[] = []; + const finder = await encryptedSavedObjects + .getClient({ + includedHiddenTypes: [type], + }) + .createPointInTimeFinderDecryptedAsInternalUser({ + type, + ...(namespace ? { namespaces: [namespace] } : undefined), + }); + + for await (const result of finder.find()) { + savedObjects = [...savedObjects, ...result.saved_objects]; + } + + try { + return response.ok({ + body: { saved_objects: savedObjects }, + }); + } catch (err) { + return response.customError({ body: err, statusCode: 500 }); + } + } + ); + router.get( { path: '/api/hidden_saved_objects/_find', diff --git a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts index b9ab3324750dde..fec7965b88df4f 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts @@ -11,7 +11,9 @@ import { PluginInitializer, SavedObjectsNamespaceType, SavedObjectUnsanitizedDoc, + SavedObject, } from '@kbn/core/server'; +import { schema } from '@kbn/config-schema'; import { EncryptedSavedObjectsPluginSetup, EncryptedSavedObjectsPluginStart, @@ -113,6 +115,40 @@ export const plugin: PluginInitializer = } ); + router.get( + { + path: '/api/saved_objects/create-point-in-time-finder-decrypted-as-internal-user', + validate: { query: schema.object({ type: schema.string() }) }, + }, + async (context, request, response) => { + const [, { encryptedSavedObjects }] = await core.getStartServices(); + const spaceId = deps.spaces.spacesService.getSpaceId(request); + const namespace = deps.spaces.spacesService.spaceIdToNamespace(spaceId); + + const { type } = request.query; + + let savedObjects: SavedObject[] = []; + const finder = await encryptedSavedObjects + .getClient() + .createPointInTimeFinderDecryptedAsInternalUser({ + type, + ...(namespace ? { namespaces: [namespace] } : undefined), + }); + + for await (const result of finder.find()) { + savedObjects = [...savedObjects, ...result.saved_objects]; + } + + try { + return response.ok({ + body: { saved_objects: savedObjects }, + }); + } catch (err) { + return response.customError({ body: err, statusCode: 500 }); + } + } + ); + registerHiddenSORoutes(router, core, deps, [HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE]); }, start() {}, diff --git a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts index e0743ee31d88ed..9c143dfa8bacd2 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts @@ -425,6 +425,67 @@ export default function ({ getService }: FtrProviderContext) { message: 'Failed to encrypt attributes', }); }); + + it('#createPointInTimeFinderDecryptedAsInternalUser decrypts and returns all attributes', async () => { + const { body: decryptedResponse } = await supertest + .get( + `${getURLAPIBaseURL()}create-point-in-time-finder-decrypted-as-internal-user?type=${encryptedSavedObjectType}` + ) + .expect(200); + expect(decryptedResponse.saved_objects[0].error).to.be(undefined); + expect(decryptedResponse.saved_objects[0].attributes).to.eql(savedObjectOriginalAttributes); + }); + + it('#createPointInTimeFinderDecryptedAsInternalUser returns error and stripped attributes if AAD attribute has changed', async () => { + const updatedAttributes = { publicProperty: randomness.string() }; + + const { body: response } = await supertest + .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`) + .set('kbn-xsrf', 'xxx') + .send({ attributes: updatedAttributes }) + .expect(200); + + expect(response.attributes).to.eql({ + publicProperty: updatedAttributes.publicProperty, + }); + + const { body: decryptedResponse } = await supertest.get( + `${getURLAPIBaseURL()}create-point-in-time-finder-decrypted-as-internal-user?type=${encryptedSavedObjectType}` + ); + + expect(decryptedResponse.saved_objects[0].error.message).to.be( + 'Unable to decrypt attribute "privateProperty"' + ); + + expect(decryptedResponse.saved_objects[0].attributes).to.eql({ + publicProperty: updatedAttributes.publicProperty, + publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, + }); + }); + + it('#createPointInTimeFinderDecryptedAsInternalUser is able to decrypt if non-AAD attribute has changed', async () => { + const updatedAttributes = { publicPropertyExcludedFromAAD: randomness.string() }; + + const { body: response } = await supertest + .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`) + .set('kbn-xsrf', 'xxx') + .send({ attributes: updatedAttributes }) + .expect(200); + + expect(response.attributes).to.eql({ + publicPropertyExcludedFromAAD: updatedAttributes.publicPropertyExcludedFromAAD, + }); + + const { body: decryptedResponse } = await supertest.get( + `${getURLAPIBaseURL()}create-point-in-time-finder-decrypted-as-internal-user?type=${encryptedSavedObjectType}` + ); + + expect(decryptedResponse.saved_objects[0].error).to.be(undefined); + expect(decryptedResponse.saved_objects[0].attributes).to.eql({ + ...savedObjectOriginalAttributes, + publicPropertyExcludedFromAAD: updatedAttributes.publicPropertyExcludedFromAAD, + }); + }); } describe('encrypted saved objects API', () => {